Learn .NET Core as JS/TS Developer

·35 min read
dotnetcsharpwebapiexpressnodejs

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/users
  • ProductsController/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 passwordHash in your API response
  • accept id or createdAt from 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 AddScoped for 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