Learn .NET Core as JS/TS Developer
Topic 1: Project Structure & App Bootstrap
When you do dotnet new webapi, you get a project. Let me map it to what you know from Express.
Express vs .NET Core bootstrap
Express (what you know):
const express = require('express');
const app = express();
app.use(express.json());
app.use('/api', router);
app.listen(3000, () => console.log('Running'));
.NET Core (Program.cs - the entry point):
var builder = WebApplication.CreateBuilder(args);
// Register services (like setting up dependencies)
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
// Middleware pipeline (like app.use() in Express)
app.UseSwagger();
app.UseSwaggerUI();
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers(); // maps all controller routes automatically
app.Run();
Key things to understand
builder.Services = where you register things (DI setup)
Think of it like telling the app "these tools exist and are available"
app.Use...() = middleware chain, exactly like Express app.use()
Order matters here too - just like Express!
app.MapControllers() = instead of manually importing and mounting each router like in Express, .NET auto-discovers all your controllers
Folder structure of a typical .NET Web API
MyApi/
├── Controllers/ → like your Express route files
├── Models/ → your data shapes / DB entities
├── DTOs/ → request/response shapes (like interfaces in TS)
├── Services/ → business logic
├── Data/ → DB context (EF Core)
├── appsettings.json → like your .env / config file
└── Program.cs → entry point (like index.js / server.js)
appsettings.json vs .env
.env (Node):
PORT=3000
DB_CONNECTION=postgres://...
JWT_SECRET=mysecret
appsettings.json (.NET):
{
"ConnectionStrings": {
"DefaultConnection": "Server=...;Database=mydb;"
},
"JwtSettings": {
"Secret": "mysecret"
},
"Logging": {
"LogLevel": {
"Default": "Information"
}
}
}
Accessed in code like:
var secret = builder.Configuration["JwtSettings:Secret"];
Topic 2: Controllers & Routing
This is the heart of .NET Web API. Think of controllers as your Express router files, but as classes.
Express vs .NET Core routing
Express (what you know):
const router = express.Router();
router.get('/users', getAllUsers);
router.get('/users/:id', getUserById);
router.post('/users', createUser);
router.put('/users/:id', updateUser);
router.delete('/users/:id', deleteUser);
.NET Core Controller:
[ApiController]
[Route("api/[controller]")] // becomes "api/users" automatically
public class UsersController : ControllerBase
{
[HttpGet]
public IActionResult GetAllUsers()
{
return Ok(new { message = "All users" });
}
[HttpGet("{id}")]
public IActionResult GetUserById(int id)
{
return Ok(new { message = $"User {id}" });
}
[HttpPost]
public IActionResult CreateUser([FromBody] object body)
{
return Created("", new { message = "User created" });
}
[HttpPut("{id}")]
public IActionResult UpdateUser(int id, [FromBody] object body)
{
return Ok(new { message = $"User {id} updated" });
}
[HttpDelete("{id}")]
public IActionResult DeleteUser(int id)
{
return NoContent(); // 204
}
}
Breaking it down piece by piece
[ApiController]
- Tells .NET "this is an API controller"
- Auto-handles model validation, bad request responses
- Like a decorator in TS -
@Controller()in NestJS if you've seen it
[Route("api/[controller]")]
[controller]is a placeholder - it auto-takes the class name minus "Controller"UsersController→/api/usersProductsController→/api/products
ControllerBase
- Base class that gives you helper methods like
Ok(),NotFound(),BadRequest() - Similar to using
res.status(200).json()in Express
Response helpers - Express vs .NET
| Express | .NET Core | Status Code |
|---|---|---|
| res.json(data) | Ok(data) | 200 |
| res.status(201).json(data) | Created("", data) | 201 |
| res.status(400).json(...) | BadRequest("msg") | 400 |
| res.status(401).json(...) | Unauthorized() | 401 |
| res.status(404).json(...) | NotFound("msg") | 404 |
| res.status(204).send() | NoContent() | 204 |
Reading data from request
Express:
router.get('/users/:id', (req, res) => {
const id = req.params.id; // route param
const page = req.query.page; // query string
const body = req.body; // request body
});
.NET Core:
[HttpGet("{id}")]
public IActionResult GetUser(
int id, // route param - auto-mapped by name
[FromQuery] int page, // query string (?page=1)
[FromBody] UserDto body // request body
)
| Express | .NET Attribute |
|---|---|
| req.params.x | method parameter matching route {x} |
| req.query.x | [FromQuery] |
| req.body | [FromBody] |
| req.headers['x'] | [FromHeader] |
IActionResult - the return type
In Express your functions return void and you call res.json().
In .NET your controller methods return the response:
// Always return IActionResult (or ActionResult<T> for typed responses)
public IActionResult GetUser(int id)
{
if (id <= 0)
return BadRequest("Invalid ID"); // returns 400
if (id == 999)
return NotFound("User not found"); // returns 404
return Ok(new { id, name = "John" }); // returns 200 with data
}
Full working example
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
// GET api/products
[HttpGet]
public IActionResult GetAll()
{
var products = new List<object>
{
new { Id = 1, Name = "Laptop", Price = 999 },
new { Id = 2, Name = "Phone", Price = 499 }
};
return Ok(products);
}
// GET api/products/1
[HttpGet("{id}")]
public IActionResult GetById(int id)
{
if (id != 1 && id != 2)
return NotFound($"Product {id} not found");
return Ok(new { Id = id, Name = "Laptop", Price = 999 });
}
// GET api/products?category=electronics&page=1
[HttpGet("search")]
public IActionResult Search([FromQuery] string category, [FromQuery] int page = 1)
{
return Ok(new { category, page, results = new[] { "Laptop", "Phone" } });
}
// POST api/products
[HttpPost]
public IActionResult Create([FromBody] object product)
{
return Created("", product); // 201
}
}
Quick mental model
Express .NET Core
──────────────────────────────────────────
router file → Controller class
router.get() → [HttpGet] method
router.post() → [HttpPost] method
req.params → route {param}
req.query → [FromQuery]
req.body → [FromBody]
res.json() → return Ok()
res.status(404) → return NotFound()
Topic 3: Models & DTOs
This is where your TypeScript knowledge will really help. You already know the pain of untyped data - Models and DTOs solve that in .NET.
What is a Model?
A Model represents your database table shape - like a Prisma/Sequelize model.
// Models/User.cs
public class User
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string PasswordHash { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
TypeScript equivalent you already know:
// This is basically what you're used to
interface User {
id: number;
name: string;
email: string;
passwordHash: string;
createdAt: Date;
}
What is a DTO?
DTO = Data Transfer Object
It's a separate class that defines what shape of data comes IN or goes OUT of your API - NOT the full DB model.
Why? Because you never want to:
- expose
passwordHashin your API response - accept
idorcreatedAtfrom the client on create
The 3 types of DTOs you'll use
User (DB Model)
├── CreateUserDto → what client sends on POST
├── UpdateUserDto → what client sends on PUT
└── UserResponseDto → what you send back to client
// DTOs/CreateUserDto.cs - client sends this
public class CreateUserDto
{
public string Name { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
// No Id, no CreatedAt, no PasswordHash
}
// DTOs/UpdateUserDto.cs - client sends this on update
public class UpdateUserDto
{
public string Name { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
}
// DTOs/UserResponseDto.cs - you send this back
public class UserResponseDto
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
// No PasswordHash - never expose this!
}
TypeScript comparison
// TS - what you're used to
interface CreateUserDto {
name: string;
email: string;
password: string;
}
interface UserResponseDto {
id: number;
name: string;
email: string;
createdAt: Date;
}
// C# - exact same concept, different syntax
public class CreateUserDto
{
public string Name { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
}
Validation with Data Annotations
In Express you'd use joi or zod for validation.
In .NET you use Data Annotations - attributes directly on your DTO properties:
using System.ComponentModel.DataAnnotations;
public class CreateUserDto
{
[Required] // field is mandatory
[StringLength(50, MinimumLength=2)] // length between 2–50
public string Name { get; set; } = string.Empty;
[Required]
[EmailAddress] // must be valid email format
public string Email { get; set; } = string.Empty;
[Required]
[MinLength(6)] // password min 6 chars
public string Password { get; set; } = string.Empty;
[Range(18, 100)] // number range
public int Age { get; set; }
}
Common annotations:
| Annotation | What it does |
|---|---|
| [Required] | Field cannot be null/empty |
| [EmailAddress] | Must be valid email |
| [MinLength(n)] | Minimum string length |
| [MaxLength(n)] | Maximum string length |
| [StringLength(max, Min=n)] | Min and max length |
| [Range(min, max)] | Number must be in range |
| [RegularExpression("pattern")] | Must match regex |
Since you added [ApiController] on your controller, validation runs automatically - if it fails, .NET returns a 400 Bad Request with details. No extra code needed!
Putting it together in a Controller
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
// POST api/users
[HttpPost]
public IActionResult CreateUser([FromBody] CreateUserDto dto)
{
// dto is already validated here because of [ApiController]
// if validation failed, it never reaches this line
// Manually map DTO → Model
var user = new User
{
Name = dto.Name,
Email = dto.Email,
PasswordHash = HashPassword(dto.Password), // hash before saving
CreatedAt = DateTime.UtcNow
};
// (save to DB - we'll cover this in EF Core topic)
// Map Model → ResponseDto before returning
var response = new UserResponseDto
{
Id = user.Id,
Name = user.Name,
Email = user.Email,
CreatedAt = user.CreatedAt
};
return Created("", response); // never return the full user model!
}
// GET api/users/1
[HttpGet("{id}")]
public IActionResult GetUser(int id)
{
// pretend we fetched from DB
var user = new User
{
Id = id,
Name = "John",
Email = "john@example.com",
PasswordHash = "hashedpassword123",
CreatedAt = DateTime.UtcNow
};
// Always map to ResponseDto
var response = new UserResponseDto
{
Id = user.Id,
Name = user.Name,
Email = user.Email,
CreatedAt = user.CreatedAt
};
return Ok(response);
}
}
The golden rule of DTOs
Client Request → DTO (CreateUserDto)
↓
Model (User) - used internally & in DB
↓
Client Response → DTO (UserResponseDto)
Never return your raw DB model directly to the client.
Quick mental model
TypeScript .NET Core
──────────────────────────────────────────
interface (input shape) → CreateUserDto class
interface (output shape) → UserResponseDto class
zod / joi validation → Data Annotations [Required] etc.
spreading/picking props → manual mapping or AutoMapper
Topic 4: Dependency Injection & Services
This is one of the biggest differences from Express - but once you get it, you'll love it. It's built into .NET, no extra libraries needed.
The problem first
In Express, you probably do something like this:
// routes/users.js
import { db } from '../db.js'
router.post('/users', async (req, res) => {
// business logic sitting directly in route handler 😬
const existing = await db.users.findOne({ email: req.body.email });
if (existing) return res.status(400).json({ error: 'Email taken' });
const user = await db.users.create(req.body);
res.status(201).json(user);
});
Business logic is mixed into the route. Hard to test, hard to reuse.
The fix - a Service layer. You already know this concept intuitively.
Service layer in Express (what you might already do)
// services/userService.js
export const createUser = async (dto) => {
const existing = await db.users.findOne({ email: dto.email });
if (existing) throw new Error('Email taken');
return await db.users.create(dto);
};
// routes/users.js
import { createUser } from '../services/userService.js';
router.post('/users', async (req, res) => {
const user = await createUser(req.body); // clean!
res.status(201).json(user);
});
.NET does the exact same thing - just more structured.
Step 1 - Create an Interface
In .NET, you first define a contract (interface) for your service:
// Services/IUserService.cs
public interface IUserService
{
UserResponseDto GetById(int id);
List<UserResponseDto> GetAll();
UserResponseDto Create(CreateUserDto dto);
UserResponseDto Update(int id, UpdateUserDto dto);
void Delete(int id);
}
Think of this like a TypeScript interface for your service:
// same idea in TS
interface IUserService {
getById(id: number): UserResponseDto;
getAll(): UserResponseDto[];
create(dto: CreateUserDto): UserResponseDto;
}
Step 2 - Implement the Service
// Services/UserService.cs
public class UserService : IUserService
{
// hardcoded list for now - will use DB later
private static List<User> _users = new List<User>();
private static int _nextId = 1;
public List<UserResponseDto> GetAll()
{
return _users.Select(u => new UserResponseDto
{
Id = u.Id,
Name = u.Name,
Email = u.Email,
CreatedAt = u.CreatedAt
}).ToList();
}
public UserResponseDto GetById(int id)
{
var user = _users.FirstOrDefault(u => u.Id == id);
if (user == null) throw new KeyNotFoundException("User not found");
return new UserResponseDto
{
Id = user.Id,
Name = user.Name,
Email = user.Email,
CreatedAt = user.CreatedAt
};
}
public UserResponseDto Create(CreateUserDto dto)
{
// business logic lives here, not in controller
var existing = _users.FirstOrDefault(u => u.Email == dto.Email);
if (existing != null) throw new Exception("Email already taken");
var user = new User
{
Id = _nextId++,
Name = dto.Name,
Email = dto.Email,
PasswordHash = dto.Password, // hash it in real app
CreatedAt = DateTime.UtcNow
};
_users.Add(user);
return new UserResponseDto
{
Id = user.Id,
Name = user.Name,
Email = user.Email,
CreatedAt = user.CreatedAt
};
}
public UserResponseDto Update(int id, UpdateUserDto dto)
{
var user = _users.FirstOrDefault(u => u.Id == id);
if (user == null) throw new KeyNotFoundException("User not found");
user.Name = dto.Name;
user.Email = dto.Email;
return new UserResponseDto
{
Id = user.Id,
Name = user.Name,
Email = user.Email,
CreatedAt = user.CreatedAt
};
}
public void Delete(int id)
{
var user = _users.FirstOrDefault(u => u.Id == id);
if (user == null) throw new KeyNotFoundException("User not found");
_users.Remove(user);
}
}
Step 3 - Register in Program.cs (DI Container)
This is the key step - you tell .NET "when someone asks for IUserService, give them UserService":
// Program.cs
builder.Services.AddControllers();
// Register your service here 👇
builder.Services.AddScoped<IUserService, UserService>();
3 types of registration - important to know:
| Method | Lifetime | Use when |
|---|---|---|
| AddSingleton | One instance for entire app lifetime | Config, caching |
| AddScoped | One instance per HTTP request | Most services, DB context |
| AddTransient | New instance every time it's requested | Lightweight, stateless |
💡 Use
AddScopedfor almost everything - it's the most common and safest for API services.
Step 4 - Inject into Controller
Now your controller just asks for the service - .NET automatically provides it:
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
private readonly IUserService _userService;
// Constructor injection - .NET auto-provides IUserService
public UsersController(IUserService userService)
{
_userService = userService;
}
[HttpGet]
public IActionResult GetAll()
{
var users = _userService.GetAll();
return Ok(users);
}
[HttpGet("{id}")]
public IActionResult GetById(int id)
{
var user = _userService.GetById(id);
return Ok(user);
}
[HttpPost]
public IActionResult Create([FromBody] CreateUserDto dto)
{
var user = _userService.Create(dto);
return Created("", user);
}
[HttpPut("{id}")]
public IActionResult Update(int id, [FromBody] UpdateUserDto dto)
{
var user = _userService.Update(id, dto);
return Ok(user);
}
[HttpDelete("{id}")]
public IActionResult Delete(int id)
{
_userService.Delete(id);
return NoContent();
}
}
Controller is now clean - no business logic, just HTTP in/out.
How DI actually works
Program.cs registers:
IUserService → UserService
HTTP Request comes in
↓
.NET creates UsersController
↓
Sees constructor needs IUserService
↓
Looks up registry → finds UserService
↓
Creates UserService, injects it
↓
Controller runs with _userService ready
You never call new UserService() yourself - .NET handles it. This is Inversion of Control.
Full picture so far
HTTP Request
↓
Controller ← receives request, calls service, returns response
↓
Service ← business logic lives here
↓
(Database) ← coming in EF Core topic
Mental model comparison
Express .NET Core
──────────────────────────────────────────
userService.js file → UserService.cs class
export functions → public methods
manual import → Constructor injection
no container → DI container in Program.cs
Topic 5: Entity Framework Core (EF Core)
This is your database layer - like Prisma or Sequelize but for .NET. EF Core lets you work with databases using C# classes instead of raw SQL.
The big picture first
Prisma (Node) EF Core (.NET)
──────────────────────────────────────────────
schema.prisma → DbContext class
prisma migrate → dotnet ef migrations add
prisma.user.create() → context.Users.Add()
prisma.user.findMany → context.Users.ToList()
Model (schema) → Entity class (your Model)
Step 1 - Install EF Core packages
dotnet add package Microsoft.EntityFrameworkCore
dotnet add package Microsoft.EntityFrameworkCore.SqlServer # for SQL Server
dotnet add package Microsoft.EntityFrameworkCore.Tools # for migrations
# OR for PostgreSQL
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL
Step 2 - Create your Entity (Model)
Your User model becomes a database table automatically:
// Models/User.cs
public class User
{
public int Id { get; set; } // becomes PRIMARY KEY auto-increment
public string Name { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string PasswordHash { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Navigation property (like a relation in Prisma)
public List<Post> Posts { get; set; } = new();
}
// Models/Post.cs
public class Post
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
public string Content { get; set; } = string.Empty;
// Foreign key
public int UserId { get; set; }
public User User { get; set; } = null!; // navigation property
}
Prisma equivalent:
model User {
id Int @id @default(autoincrement())
name String
email String
posts Post[]
}
model Post {
id Int @id @default(autoincrement())
title String
userId Int
user User @relation(fields: [userId], references: [id])
}
Step 3 - Create DbContext
DbContext is the heart of EF Core - like your Prisma client:
// Data/AppDbContext.cs
using Microsoft.EntityFrameworkCore;
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
// Each DbSet = one database table
public DbSet<User> Users { get; set; } // → "Users" table
public DbSet<Post> Posts { get; set; } // → "Posts" table
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Extra configuration (optional)
modelBuilder.Entity<User>()
.HasIndex(u => u.Email)
.IsUnique(); // unique constraint on email
modelBuilder.Entity<Post>()
.HasOne(p => p.User)
.WithMany(u => u.Posts)
.HasForeignKey(p => p.UserId);
}
}
Step 4 - Register in Program.cs
// Program.cs
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(
builder.Configuration.GetConnectionString("DefaultConnection")
)
// OR for PostgreSQL:
// options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))
);
appsettings.json:
{
"ConnectionStrings": {
"DefaultConnection": "Server=localhost;Database=MyApiDb;Trusted_Connection=True;"
}
}
Step 5 - Migrations (like Prisma migrate)
# Create migration (like prisma migrate dev)
dotnet ef migrations add InitialCreate
# Apply migration to database (creates actual tables)
dotnet ef database update
This generates a Migrations/ folder - don't edit these files manually, just like Prisma migrations.
Step 6 - Use DbContext in Service
Now replace the fake _users list in your UserService with real DB calls:
// Services/UserService.cs
public class UserService : IUserService
{
private readonly AppDbContext _context;
// Inject DbContext - same DI pattern as before
public UserService(AppDbContext context)
{
_context = context;
}
public List<UserResponseDto> GetAll()
{
return _context.Users
.Select(u => new UserResponseDto
{
Id = u.Id,
Name = u.Name,
Email = u.Email,
CreatedAt = u.CreatedAt
})
.ToList();
}
public UserResponseDto GetById(int id)
{
var user = _context.Users.FirstOrDefault(u => u.Id == id);
if (user == null) throw new KeyNotFoundException("User not found");
return new UserResponseDto
{
Id = user.Id,
Name = user.Name,
Email = user.Email,
CreatedAt = user.CreatedAt
};
}
public UserResponseDto Create(CreateUserDto dto)
{
var existing = _context.Users.FirstOrDefault(u => u.Email == dto.Email);
if (existing != null) throw new Exception("Email already taken");
var user = new User
{
Name = dto.Name,
Email = dto.Email,
PasswordHash = dto.Password, // hash in real app
CreatedAt = DateTime.UtcNow
};
_context.Users.Add(user); // like prisma.user.create()
_context.SaveChanges(); // commits to DB - don't forget this!
return new UserResponseDto
{
Id = user.Id,
Name = user.Name,
Email = user.Email,
CreatedAt = user.CreatedAt
};
}
public UserResponseDto Update(int id, UpdateUserDto dto)
{
var user = _context.Users.FirstOrDefault(u => u.Id == id);
if (user == null) throw new KeyNotFoundException("User not found");
user.Name = dto.Name;
user.Email = dto.Email;
_context.SaveChanges(); // save changes to DB
return new UserResponseDto
{
Id = user.Id,
Name = user.Name,
Email = user.Email,
CreatedAt = user.CreatedAt
};
}
public void Delete(int id)
{
var user = _context.Users.FirstOrDefault(u => u.Id == id);
if (user == null) throw new KeyNotFoundException("User not found");
_context.Users.Remove(user); // mark for deletion
_context.SaveChanges(); // commits deletion
}
}
Common EF Core queries - compared to Prisma
// GET ALL
_context.Users.ToList();
// prisma: prisma.user.findMany()
// GET BY ID
_context.Users.FirstOrDefault(u => u.Id == id);
// prisma: prisma.user.findUnique({ where: { id } })
// FILTER
_context.Users.Where(u => u.Name == "John").ToList();
// prisma: prisma.user.findMany({ where: { name: "John" } })
// WITH RELATED DATA (joins)
_context.Users.Include(u => u.Posts).ToList();
// prisma: prisma.user.findMany({ include: { posts: true } })
// ORDER BY
_context.Users.OrderBy(u => u.Name).ToList();
// prisma: prisma.user.findMany({ orderBy: { name: 'asc' } })
// PAGINATION
_context.Users.Skip(10).Take(5).ToList();
// prisma: prisma.user.findMany({ skip: 10, take: 5 })
// ADD
_context.Users.Add(user);
_context.SaveChanges();
// prisma: prisma.user.create({ data: user })
// DELETE
_context.Users.Remove(user);
_context.SaveChanges();
// prisma: prisma.user.delete({ where: { id } })
Async versions (recommended)
Always use async in real apps - same as Node:
// async version - always prefer this
public async Task<List<UserResponseDto>> GetAllAsync()
{
return await _context.Users
.Select(u => new UserResponseDto { Id = u.Id, Name = u.Name })
.ToListAsync(); // async version of ToList()
}
public async Task<UserResponseDto> CreateAsync(CreateUserDto dto)
{
_context.Users.Add(user);
await _context.SaveChangesAsync(); // async save
return response;
}
And update your interface and controller methods to async Task<IActionResult>:
// Controller
[HttpGet]
public async Task<IActionResult> GetAll()
{
var users = await _userService.GetAllAsync();
return Ok(users);
}
Full flow recap
HTTP POST /api/users
↓
UsersController.Create(dto)
↓
UserService.Create(dto) ← business logic
↓
AppDbContext.Users.Add(user) ← EF Core
↓
SaveChangesAsync() ← SQL INSERT runs here
↓
Return UserResponseDto
↓
200 OK { id, name, email }
Mental model
Prisma/Sequelize EF Core
──────────────────────────────────────────
PrismaClient → AppDbContext
schema.prisma model → Entity class (User.cs)
prisma.user → _context.Users (DbSet)
prisma migrate dev → dotnet ef migrations add
prisma db push → dotnet ef database update
findMany() → ToListAsync()
findUnique() → FirstOrDefaultAsync()
create() → Add() + SaveChangesAsync()
delete() → Remove() + SaveChangesAsync()
Topic 6: Middleware & Error Handling
You already understand middleware from Express - this will feel very familiar. The pipeline concept is identical, just different syntax.
Middleware pipeline - concept is same
Express:
app.use((req, res, next) => {
console.log(`${req.method} ${req.path}`);
next(); // pass to next middleware
});
.NET Core - same idea:
app.Use(async (context, next) =>
{
Console.WriteLine($"{context.Request.Method} {context.Request.Path}");
await next(); // pass to next middleware
});
Pipeline order - matters exactly like Express
// Program.cs - order matters!
app.UseHttpsRedirection(); // 1st
app.UseAuthentication(); // 2nd - who are you?
app.UseAuthorization(); // 3rd - what can you do?
app.MapControllers(); // last - hit the controller
Same rule as Express - request flows top to bottom, response flows bottom to top.
Request
↓
UseHttpsRedirection
↓
UseAuthentication
↓
UseAuthorization
↓
Controller
↑
Response flows back up
The problem - unhandled exceptions right now
Currently in your UserService you throw exceptions:
throw new KeyNotFoundException("User not found");
throw new Exception("Email already taken");
But in your controller you don't catch them - so .NET returns an ugly 500 error for everything. You need global error handling.
Solution 1 - Global Exception Handling Middleware
This is like Express's error middleware (err, req, res, next):
Express:
// error middleware - 4 params
app.use((err, req, res, next) => {
console.error(err);
res.status(500).json({ error: err.message });
});
.NET - create a custom middleware class:
// Middleware/ErrorHandlingMiddleware.cs
public class ErrorHandlingMiddleware
{
private readonly RequestDelegate _next;
public ErrorHandlingMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task Invoke(HttpContext context)
{
try
{
await _next(context); // run the rest of the pipeline
}
catch (KeyNotFoundException ex)
{
// 404 errors
context.Response.StatusCode = 404;
context.Response.ContentType = "application/json";
await context.Response.WriteAsJsonAsync(new
{
error = ex.Message,
statusCode = 404
});
}
catch (UnauthorizedAccessException ex)
{
// 401 errors
context.Response.StatusCode = 401;
context.Response.ContentType = "application/json";
await context.Response.WriteAsJsonAsync(new
{
error = ex.Message,
statusCode = 401
});
}
catch (Exception ex)
{
// catch-all 500 errors
context.Response.StatusCode = 500;
context.Response.ContentType = "application/json";
await context.Response.WriteAsJsonAsync(new
{
error = "Something went wrong",
statusCode = 500
});
}
}
}
Register it in Program.cs - before everything else:
// Program.cs
app.UseMiddleware<ErrorHandlingMiddleware>(); // 👈 first in pipeline
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
Now your service can throw exceptions freely and they'll always return clean JSON errors.
Solution 2 - Custom Exception Classes (cleaner approach)
Instead of throwing generic exceptions, create your own exception types:
// Exceptions/AppException.cs
public class AppException : Exception
{
public int StatusCode { get; }
public AppException(string message, int statusCode = 400) : base(message)
{
StatusCode = statusCode;
}
}
// Exceptions/NotFoundException.cs
public class NotFoundException : AppException
{
public NotFoundException(string message) : base(message, 404) { }
}
// Exceptions/UnauthorizedException.cs
public class UnauthorizedException : AppException
{
public UnauthorizedException(string message) : base(message, 401) { }
}
Update middleware to handle custom exceptions:
catch (AppException ex)
{
context.Response.StatusCode = ex.StatusCode;
context.Response.ContentType = "application/json";
await context.Response.WriteAsJsonAsync(new
{
error = ex.Message,
statusCode = ex.StatusCode
});
}
catch (Exception ex)
{
context.Response.StatusCode = 500;
context.Response.ContentType = "application/json";
await context.Response.WriteAsJsonAsync(new
{
error = "Something went wrong",
statusCode = 500
});
}
Now your service looks clean:
public async Task<UserResponseDto> GetByIdAsync(int id)
{
var user = await _context.Users.FirstOrDefaultAsync(u => u.Id == id);
if (user == null) throw new NotFoundException("User not found"); // clean!
...
}
public async Task<UserResponseDto> CreateAsync(CreateUserDto dto)
{
var existing = await _context.Users.FirstOrDefaultAsync(u => u.Email == dto.Email);
if (existing != null) throw new AppException("Email already taken", 400); // clean!
...
}
Custom Logging Middleware
Like Morgan in Express (app.use(morgan('dev'))):
// Middleware/LoggingMiddleware.cs
public class LoggingMiddleware
{
private readonly RequestDelegate _next;
public LoggingMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task Invoke(HttpContext context)
{
var start = DateTime.UtcNow;
Console.WriteLine($"→ {context.Request.Method} {context.Request.Path}");
await _next(context);
var duration = DateTime.UtcNow - start;
Console.WriteLine($"← {context.Response.StatusCode} ({duration.TotalMilliseconds}ms)");
}
}
Register it:
app.UseMiddleware<LoggingMiddleware>();
app.UseMiddleware<ErrorHandlingMiddleware>();
Built-in middleware you'll use often
app.UseHttpsRedirection(); // redirect HTTP → HTTPS
app.UseStaticFiles(); // serve static files
app.UseCors(); // handle CORS (like cors npm package)
app.UseAuthentication(); // JWT auth
app.UseAuthorization(); // role-based access
app.UseRateLimiter(); // rate limiting
CORS setup (very common need)
Like cors npm package in Express:
Express:
app.use(cors({ origin: 'http://localhost:3000' }));
.NET:
// Program.cs
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowFrontend", policy =>
{
policy.WithOrigins("http://localhost:3000")
.AllowAnyHeader()
.AllowAnyMethod();
});
});
// after app = builder.Build()
app.UseCors("AllowFrontend"); // 👈 must be before MapControllers
app.MapControllers();
Full Program.cs - everything together
var builder = WebApplication.CreateBuilder(args);
// Services
builder.Services.AddControllers();
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowFrontend", policy =>
policy.WithOrigins("http://localhost:3000")
.AllowAnyHeader()
.AllowAnyMethod());
});
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
// Middleware pipeline - ORDER MATTERS
app.UseMiddleware<ErrorHandlingMiddleware>(); // 1. catch all errors
app.UseMiddleware<LoggingMiddleware>(); // 2. log requests
app.UseHttpsRedirection(); // 3. force HTTPS
app.UseCors("AllowFrontend"); // 4. CORS
app.UseAuthentication(); // 5. who are you
app.UseAuthorization(); // 6. what can you do
app.UseSwagger(); // 7. API docs
app.UseSwaggerUI();
app.MapControllers(); // 8. route to controllers
app.Run();
Mental model
Express .NET Core
──────────────────────────────────────────
app.use(fn) → app.UseMiddleware<T>()
(err,req,res,next) → try/catch in middleware
morgan → LoggingMiddleware
cors npm package → builder.Services.AddCors()
custom error classes → AppException : Exception
Full flow with error handling
Request → LoggingMiddleware
↓
ErrorHandlingMiddleware (wraps everything in try/catch)
↓
Controller → Service → throws NotFoundException
↑
ErrorHandlingMiddleware catches it → returns 404 JSON
↑
LoggingMiddleware logs response time
↑
Response ← client gets clean { error, statusCode }
Topic 7: JWT Authentication
You already know JWT from Node.js - the concept is 100% identical. Same token structure, same flow. Just different implementation.
Quick JWT recap - same in both
1. User logs in with email/password
2. Server validates → generates JWT token
3. Client stores token, sends it in every request header
4. Server validates token → allows or rejects request
Install packages
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
dotnet add package System.IdentityModel.Tokens.Jwt
dotnet add package BCrypt.Net-Next # for password hashing
Step 1 - Add JWT config to appsettings.json
{
"ConnectionStrings": {
"DefaultConnection": "Server=...;"
},
"JwtSettings": {
"Secret": "your-super-secret-key-min-32-characters-long",
"Issuer": "MyApi",
"Audience": "MyApiUsers",
"ExpiryInDays": 7
}
}
Step 2 - Create Auth DTOs
// DTOs/RegisterDto.cs
public class RegisterDto
{
[Required]
public string Name { get; set; } = string.Empty;
[Required]
[EmailAddress]
public string Email { get; set; } = string.Empty;
[Required]
[MinLength(6)]
public string Password { get; set; } = string.Empty;
}
// DTOs/LoginDto.cs
public class LoginDto
{
[Required]
[EmailAddress]
public string Email { get; set; } = string.Empty;
[Required]
public string Password { get; set; } = string.Empty;
}
// DTOs/AuthResponseDto.cs
public class AuthResponseDto
{
public string Token { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
}
Step 3 - Create Token Service
// Services/ITokenService.cs
public interface ITokenService
{
string GenerateToken(User user);
}
// Services/TokenService.cs
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.IdentityModel.Tokens;
public class TokenService : ITokenService
{
private readonly IConfiguration _config;
public TokenService(IConfiguration config)
{
_config = config;
}
public string GenerateToken(User user)
{
// Claims = payload data inside the token
// like { id, email, role } in Node JWT
var claims = new List<Claim>
{
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Email, user.Email),
new Claim(ClaimTypes.Name, user.Name),
new Claim(ClaimTypes.Role, user.Role) // "Admin", "User" etc
};
// signing key - like your JWT_SECRET in Node
var key = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(_config["JwtSettings:Secret"]!)
);
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: _config["JwtSettings:Issuer"],
audience: _config["JwtSettings:Audience"],
claims: claims,
expires: DateTime.UtcNow.AddDays(
double.Parse(_config["JwtSettings:ExpiryInDays"]!)
),
signingCredentials: credentials
);
// serialize token to string - like jwt.sign() in Node
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
Node equivalent you know:
const token = jwt.sign(
{ id: user.id, email: user.email, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '7d' }
);
Step 4 - Create Auth Service
// Services/IAuthService.cs
public interface IAuthService
{
Task<AuthResponseDto> RegisterAsync(RegisterDto dto);
Task<AuthResponseDto> LoginAsync(LoginDto dto);
}
// Services/AuthService.cs
public class AuthService : IAuthService
{
private readonly AppDbContext _context;
private readonly ITokenService _tokenService;
public AuthService(AppDbContext context, ITokenService tokenService)
{
_context = context;
_tokenService = tokenService;
}
public async Task<AuthResponseDto> RegisterAsync(RegisterDto dto)
{
// check if email exists
var existing = await _context.Users
.FirstOrDefaultAsync(u => u.Email == dto.Email);
if (existing != null)
throw new AppException("Email already registered", 400);
// hash password - like bcrypt.hash() in Node
var user = new User
{
Name = dto.Name,
Email = dto.Email,
PasswordHash = BCrypt.Net.BCrypt.HashPassword(dto.Password),
Role = "User",
CreatedAt = DateTime.UtcNow
};
_context.Users.Add(user);
await _context.SaveChangesAsync();
// generate token
var token = _tokenService.GenerateToken(user);
return new AuthResponseDto
{
Token = token,
Name = user.Name,
Email = user.Email
};
}
public async Task<AuthResponseDto> LoginAsync(LoginDto dto)
{
var user = await _context.Users
.FirstOrDefaultAsync(u => u.Email == dto.Email);
// verify password - like bcrypt.compare() in Node
if (user == null || !BCrypt.Net.BCrypt.Verify(dto.Password, user.PasswordHash))
throw new AppException("Invalid email or password", 401);
var token = _tokenService.GenerateToken(user);
return new AuthResponseDto
{
Token = token,
Name = user.Name,
Email = user.Email
};
}
}
Step 5 - Create Auth Controller
// Controllers/AuthController.cs
[ApiController]
[Route("api/[controller]")]
public class AuthController : ControllerBase
{
private readonly IAuthService _authService;
public AuthController(IAuthService authService)
{
_authService = authService;
}
// POST api/auth/register
[HttpPost("register")]
public async Task<IActionResult> Register([FromBody] RegisterDto dto)
{
var response = await _authService.RegisterAsync(dto);
return Created("", response);
}
// POST api/auth/login
[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] LoginDto dto)
{
var response = await _authService.LoginAsync(dto);
return Ok(response);
}
}
Step 6 - Configure JWT in Program.cs
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;
// register services
builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddScoped<IAuthService, AuthService>();
// configure JWT authentication
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["JwtSettings:Secret"]!)
),
ValidateIssuer = true,
ValidIssuer = builder.Configuration["JwtSettings:Issuer"],
ValidateAudience = true,
ValidAudience = builder.Configuration["JwtSettings:Audience"],
ValidateLifetime = true // checks token expiry
};
});
// middleware pipeline
app.UseAuthentication(); // 👈 before UseAuthorization
app.UseAuthorization();
Step 7 - Protect Routes with [Authorize]
Like authMiddleware in Express:
Express:
router.get('/profile', authMiddleware, (req, res) => { ... });
.NET - use [Authorize] attribute:
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
// ✅ public route - no auth needed
[HttpGet("public")]
public IActionResult PublicRoute()
{
return Ok("Anyone can see this");
}
// 🔒 protected - must have valid JWT
[Authorize]
[HttpGet("profile")]
public IActionResult GetProfile()
{
return Ok("Only logged in users see this");
}
// 🔒 admin only
[Authorize(Roles = "Admin")]
[HttpDelete("{id}")]
public IActionResult DeleteUser(int id)
{
return Ok("Only admins can delete");
}
}
// 🔒 protect entire controller at once
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
// all routes here require auth
// override one route to be public
[AllowAnonymous]
[HttpGet("public-orders")]
public IActionResult PublicOrders()
{
return Ok("This one is public");
}
}
Step 8 - Read current user from token
Like req.user in Express:
Express:
router.get('/profile', authMiddleware, (req, res) => {
const userId = req.user.id; // set by middleware
});
.NET - read from User claims:
[Authorize]
[HttpGet("profile")]
public async Task<IActionResult> GetProfile()
{
// read claims from token - like req.user in Express
var userId = int.Parse(User.FindFirst(ClaimTypes.NameIdentifier)!.Value);
var email = User.FindFirst(ClaimTypes.Email)!.Value;
var role = User.FindFirst(ClaimTypes.Role)!.Value;
var user = await _userService.GetByIdAsync(userId);
return Ok(user);
}
Full auth flow
POST /api/auth/register { name, email, password }
↓
AuthService.RegisterAsync()
↓
BCrypt.HashPassword(password)
↓
Save user to DB
↓
TokenService.GenerateToken(user)
↓
Return { token, name, email }
─────────────────────────────────
POST /api/auth/login { email, password }
↓
Find user by email
↓
BCrypt.Verify(password, hash)
↓
TokenService.GenerateToken(user)
↓
Return { token, name, email }
─────────────────────────────────
GET /api/users/profile
Headers: { Authorization: "Bearer <token>" }
↓
JwtBearer middleware validates token
↓
[Authorize] passes ✅
↓
User.FindFirst() reads claims
↓
Return profile data
Mental model
Node/Express .NET Core
──────────────────────────────────────────
jwt.sign() → JwtSecurityTokenHandler.WriteToken()
jwt.verify() → TokenValidationParameters (auto)
bcrypt.hash() → BCrypt.Net.BCrypt.HashPassword()
bcrypt.compare() → BCrypt.Net.BCrypt.Verify()
authMiddleware → [Authorize] attribute
req.user → User.FindFirst(ClaimTypes.X)
role check middleware → [Authorize(Roles = "Admin")]
Topic 8: Swagger & API Documentation
This is one of the best things about .NET - Swagger is built in by default. No extra setup like in Express.
What is Swagger?
It's an auto-generated interactive API docs UI - like Postman but lives inside your app at /swagger.
Express .NET Core
──────────────────────────────────────────
swagger-jsdoc (manual) → Built-in, auto-generated
swagger-ui-express → Built-in UI
write JSDoc comments → XML comments + Attributes
no auth support default → JWT support built-in
Basic setup - already done by default
When you do dotnet new webapi, this is already in Program.cs:
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
app.UseSwagger();
app.UseSwaggerUI();
Run your app → visit https://localhost:PORT/swagger → you already have docs! ✅
Step 1 - Add JWT support to Swagger UI
By default Swagger doesn't know about your JWT auth. Add this so you can test protected routes directly in Swagger:
// Program.cs
builder.Services.AddSwaggerGen(options =>
{
// basic API info
options.SwaggerDoc("v1", new OpenApiInfo
{
Title = "My API",
Version = "v1",
Description = "A sample ASP.NET Core Web API",
Contact = new OpenApiContact
{
Name = "Your Name",
Email = "you@example.com"
}
});
// tell Swagger about JWT bearer auth
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Name = "Authorization",
Type = SecuritySchemeType.Http,
Scheme = "Bearer",
BearerFormat = "JWT",
In = ParameterLocation.Header,
Description = "Enter your JWT token here. Example: eyJhbGci..."
});
// apply JWT auth to all endpoints
options.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "Bearer"
}
},
Array.Empty<string>()
}
});
});
Now in Swagger UI you'll see an Authorize 🔒 button - paste your JWT token there and all requests will include it automatically.
Step 2 - Describe your endpoints with attributes
.NET reads your code and auto-generates docs - but you can enrich them:
[ApiController]
[Route("api/[controller]")]
[Produces("application/json")] // tells Swagger this returns JSON
public class UsersController : ControllerBase
{
/// <summary>
/// Get all users
/// </summary>
/// <returns>List of all users</returns>
[HttpGet]
[ProducesResponseType(typeof(List<UserResponseDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetAll()
{
var users = await _userService.GetAllAsync();
return Ok(users);
}
/// <summary>
/// Get a user by ID
/// </summary>
/// <param name="id">The user ID</param>
/// <returns>A single user</returns>
[HttpGet("{id}")]
[ProducesResponseType(typeof(UserResponseDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetById(int id)
{
var user = await _userService.GetByIdAsync(id);
return Ok(user);
}
/// <summary>
/// Create a new user
/// </summary>
/// <param name="dto">User creation data</param>
/// <returns>Newly created user</returns>
[HttpPost]
[ProducesResponseType(typeof(UserResponseDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> Create([FromBody] CreateUserDto dto)
{
var user = await _userService.CreateAsync(dto);
return Created("", user);
}
}
What each thing does:
| Attribute | What it adds to Swagger |
|---|---|
| /// <summary> | Description of the endpoint |
| /// <param> | Description of a parameter |
| [ProducesResponseType] | Shows possible response codes & shapes |
| [Produces("application/json")] | Shows content type |
Step 3 - Describe your DTOs with XML comments
/// <summary>
/// Data required to create a new user
/// </summary>
public class CreateUserDto
{
/// <summary>Full name of the user</summary>
/// <example>John Doe</example>
[Required]
public string Name { get; set; } = string.Empty;
/// <summary>Valid email address</summary>
/// <example>john@example.com</example>
[Required]
[EmailAddress]
public string Email { get; set; } = string.Empty;
/// <summary>Password - minimum 6 characters</summary>
/// <example>secret123</example>
[Required]
[MinLength(6)]
public string Password { get; set; } = string.Empty;
}
<example> tag → Swagger shows example values in the UI automatically.
Step 4 - Enable XML docs in project file
For /// comments to appear in Swagger, enable XML generation:
<!-- MyApi.csproj -->
<PropertyGroup>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn> <!-- suppress missing comment warnings -->
</PropertyGroup>
Then tell SwaggerGen to use it:
builder.Services.AddSwaggerGen(options =>
{
// ... previous config ...
// include XML comments
var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
options.IncludeXmlComments(xmlPath);
});
Add this import at top of Program.cs:
using System.Reflection;
Step 5 - Group endpoints with Tags
[ApiController]
[Route("api/[controller]")]
[Tags("Users")] // groups all routes under "Users" in Swagger UI
public class UsersController : ControllerBase { }
[ApiController]
[Route("api/[controller]")]
[Tags("Authentication")]
public class AuthController : ControllerBase { }
Step 6 - Only show Swagger in Development
You don't want Swagger exposed in production:
// Program.cs
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI(options =>
{
options.SwaggerEndpoint("/swagger/v1/swagger.json", "My API v1");
options.RoutePrefix = string.Empty; // serve at root "/" instead of "/swagger"
});
}
Full Program.cs - everything together now
using System.Reflection;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models;
using System.Text;
var builder = WebApplication.CreateBuilder(args);
// Controllers
builder.Services.AddControllers();
// Database
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
// Services
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<ITokenService, TokenService>();
// CORS
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowFrontend", policy =>
policy.WithOrigins("http://localhost:3000")
.AllowAnyHeader()
.AllowAnyMethod());
});
// JWT Auth
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["JwtSettings:Secret"]!)
),
ValidateIssuer = true,
ValidIssuer = builder.Configuration["JwtSettings:Issuer"],
ValidateAudience = true,
ValidAudience = builder.Configuration["JwtSettings:Audience"],
ValidateLifetime = true
};
});
// Swagger
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new OpenApiInfo { Title = "My API", Version = "v1" });
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Name = "Authorization",
Type = SecuritySchemeType.Http,
Scheme = "Bearer",
BearerFormat = "JWT",
In = ParameterLocation.Header
});
options.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "Bearer"
}
},
Array.Empty<string>()
}
});
var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
options.IncludeXmlComments(xmlPath);
});
var app = builder.Build();
// Pipeline
app.UseMiddleware<ErrorHandlingMiddleware>();
app.UseMiddleware<LoggingMiddleware>();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseCors("AllowFrontend");
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
What Swagger UI gives you
/swagger
├── 🔒 Authorize button → paste JWT token
├── Authentication
│ ├── POST /api/auth/register
│ └── POST /api/auth/login
├── Users
│ ├── GET /api/users
│ ├── GET /api/users/{id}
│ ├── POST /api/users
│ ├── PUT /api/users/{id}
│ └── DELETE /api/users/{id}
└── Each endpoint shows:
├── Description (from /// comments)
├── Request body schema
├── Example values
└── Possible response codes
Mental model
Express .NET Core
──────────────────────────────────────────
swagger-jsdoc → Built-in AddSwaggerGen()
@swagger JSDoc comments → /// XML comments
swagger-ui-express → Built-in UseSwaggerUI()
manual schema writing → Auto-generated from DTOs
no JWT support default → AddSecurityDefinition()
🎉 You now have a complete .NET Core API with:
- ✅ Controllers & Routing
- ✅ Models & DTOs with Validation
- ✅ Dependency Injection & Services
- ✅ Entity Framework Core (Database)
- ✅ Middleware & Error Handling
- ✅ JWT Authentication
- ✅ Swagger Documentation