Angular 22 CRUD Example with ASP.NET Core Web API and .NET 10

In this article, we will build a CRUD (Create, Read, Update, Delete) application with Angular, an ASP.NET Core Web API and a SQL Server database. Along the way we will implement a data table with searching, sorting, pagination and multi-select bulk delete, plus a cascading dropdown (country, state and city), a date picker and radio buttons.

The whole user interface uses the Angular Material theme, so it stays clean and responsive. By the end, you will have a form for adding and editing employees and a searchable, sortable table with bulk delete.

The tutorial is written for developers who are new to connecting Angular with ASP.NET Core, and it assumes only basic C# and TypeScript knowledge.

The examples use Angular 22, Angular Material and .NET 10 (LTS), the current releases at the time of writing. .NET 8 and .NET 9 reach end of support on November 10, 2026, so new projects should start on .NET 10.

You can check the current status on the .NET support policy page and the Angular releases page.

Angular CRUD Example with ASP.NET Core Web API and SQL Server

The tutorial is written step by step, so it should be easy to follow for beginners. We will create the database, the Web API, the Angular service, the component and the template.

What You Will Build

  1. Create a simple CRUD operation using Angular with an ASP.NET Core Web API.
  2. Use the Angular Material theme.
  3. Implement a cascading dropdown (country, state and city).
  4. Implement searching, sorting and pagination in a table.
  5. Implement multi-select bulk delete with checkboxes.

Prerequisites

  • The .NET 10 SDK.
  • A Node.js version that your Angular version supports. Angular 22 no longer supports Node.js 20, and it needs a recent Node.js 22 release (22.22 or later), Node.js 24 or Node.js 26. The exact minimum versions are listed in the Angular version compatibility table.
  • SQL Server (Express, Developer or LocalDB is enough) and SQL Server Management Studio or any other SQL client.
  • A code editor such as Visual Studio Code or Visual Studio.

Implementation

We will store the basic details of employees, so let's start with the database. The Web API project and the Angular project will be two separate projects.

By the end, these are the files you will have created or changed:

  • Web API (AngularCrudApi): Models/Entities.cs, Models/EmployeeDtos.cs, Data/AppDbContext.cs, Controllers/EmployeeController.cs, and the changes to Program.cs and appsettings.json.
  • Angular app (angular-crud): src/app/employee.model.ts, src/app/employee.service.ts, the employee component files (TypeScript, HTML and CSS), and the changes to app.config.ts, app.ts and app.html.

Step 1: Create the Database and Tables

We need four tables: one for the employees, and three for the country, state and city values used by the cascading dropdown. The foreign keys make sure an employee can only point to a location that exists.

sql
CREATE DATABASE AngularCrudDB;
GO

USE AngularCrudDB;
GO

CREATE TABLE dbo.CountryMaster
(
    CountryId   INT IDENTITY(1, 1) NOT NULL,
    CountryName VARCHAR(50) NOT NULL,
    CONSTRAINT PK_CountryMaster PRIMARY KEY CLUSTERED (CountryId)
);

CREATE TABLE dbo.StateMaster
(
    StateId   INT IDENTITY(1, 1) NOT NULL,
    StateName VARCHAR(50) NOT NULL,
    CountryId INT NOT NULL,
    CONSTRAINT PK_StateMaster PRIMARY KEY CLUSTERED (StateId),
    CONSTRAINT FK_StateMaster_Country FOREIGN KEY (CountryId) REFERENCES dbo.CountryMaster (CountryId)
);

CREATE TABLE dbo.CityMaster
(
    CityId    INT IDENTITY(1, 1) NOT NULL,
    CityName  VARCHAR(50) NOT NULL,
    StateId   INT NOT NULL,
    CountryId INT NOT NULL,
    CONSTRAINT PK_CityMaster PRIMARY KEY CLUSTERED (CityId),
    CONSTRAINT FK_CityMaster_State FOREIGN KEY (StateId) REFERENCES dbo.StateMaster (StateId),
    CONSTRAINT FK_CityMaster_Country FOREIGN KEY (CountryId) REFERENCES dbo.CountryMaster (CountryId)
);

CREATE TABLE dbo.tblEmployeeMaster
(
    EmpId       INT IDENTITY(1, 1) NOT NULL,
    FirstName   VARCHAR(50) NOT NULL,
    LastName    VARCHAR(50) NOT NULL,
    DateOfBirth DATE NOT NULL,
    EmailId     NVARCHAR(100) NOT NULL,
    Gender      VARCHAR(10) NOT NULL,
    CountryId   INT NOT NULL,
    StateId     INT NOT NULL,
    CityId      INT NOT NULL,
    Address     VARCHAR(100) NOT NULL,
    Pincode     VARCHAR(6) NOT NULL,
    CONSTRAINT PK_tblEmployeeMaster PRIMARY KEY CLUSTERED (EmpId),
    CONSTRAINT FK_Employee_Country FOREIGN KEY (CountryId) REFERENCES dbo.CountryMaster (CountryId),
    CONSTRAINT FK_Employee_State FOREIGN KEY (StateId) REFERENCES dbo.StateMaster (StateId),
    CONSTRAINT FK_Employee_City FOREIGN KEY (CityId) REFERENCES dbo.CityMaster (CityId)
);

The date of birth uses the DATE type, because we only need the day and not a time.

Now add a few sample records for the country, state and city tables. Run the whole script below as one batch. It reads each generated ID with SCOPE_IDENTITY(), so it does not depend on the identity values starting at 1.

Run it only once, because running it again inserts duplicate rows.

sql
DECLARE @IndiaId INT, @GujaratId INT;

INSERT INTO dbo.CountryMaster (CountryName)
VALUES ('India');
SET @IndiaId = SCOPE_IDENTITY();

INSERT INTO dbo.StateMaster (StateName, CountryId)
VALUES ('Andhra Pradesh', @IndiaId);

INSERT INTO dbo.StateMaster (StateName, CountryId)
VALUES ('Gujarat', @IndiaId);
SET @GujaratId = SCOPE_IDENTITY();

INSERT INTO dbo.StateMaster (StateName, CountryId)
VALUES ('Kerala', @IndiaId);

INSERT INTO dbo.CityMaster (CityName, StateId, CountryId)
VALUES ('Ahmedabad', @GujaratId, @IndiaId),
       ('Surat', @GujaratId, @IndiaId),
       ('Vadodara', @GujaratId, @IndiaId),
       ('Rajkot', @GujaratId, @IndiaId);

This sample data has one country, and cities only for Gujarat. When you test the cascading dropdown, Andhra Pradesh and Kerala will therefore show an empty city list. Add more rows if you want to try them.

Step 2: Create the ASP.NET Core Web API Project

Open a terminal in the folder where you keep your projects and run the following commands. The first command creates a Web API project that uses controllers. The second moves into the project folder, and the third adds the SQL Server provider for Entity Framework Core.

powershell
dotnet new webapi --use-controllers -n AngularCrudApi
cd AngularCrudApi
dotnet add package Microsoft.EntityFrameworkCore.SqlServer

The template also generates a sample WeatherForecast controller and model. You can delete them, because we do not use them.

Entity Framework Core major versions follow the .NET major versions, so keep the package on the same major version as your target framework (10.x for net10.0). If dotnet add package ever picks a newer major version than your project supports, choose a 10.x release with the --version option.

In this tutorial we write the entity classes ourselves, which is simple and works on every operating system. If you already have a large database, you can generate the classes with dotnet ef dbcontext scaffold instead. That command needs the dotnet-ef tool and the Microsoft.EntityFrameworkCore.Design package.

Step 3: Add the Models and the DbContext

Create a Models folder and add the entity classes below. Each class maps to one table.

Models/Entities.cs

csharp
namespace AngularCrudApi.Models;

public class Country
{
    public int CountryId { get; set; }
    public string CountryName { get; set; } = string.Empty;
}

public class State
{
    public int StateId { get; set; }
    public string StateName { get; set; } = string.Empty;
    public int CountryId { get; set; }
}

public class City
{
    public int CityId { get; set; }
    public string CityName { get; set; } = string.Empty;
    public int StateId { get; set; }
    public int CountryId { get; set; }
}

public class Employee
{
    public int EmpId { get; set; }
    public string FirstName { get; set; } = string.Empty;
    public string LastName { get; set; } = string.Empty;
    public DateOnly DateOfBirth { get; set; }
    public string EmailId { get; set; } = string.Empty;
    public string Gender { get; set; } = string.Empty;
    public int CountryId { get; set; }
    public int StateId { get; set; }
    public int CityId { get; set; }
    public string Address { get; set; } = string.Empty;
    public string Pincode { get; set; } = string.Empty;
}

We should not accept or return the entity classes directly from the API, because a client could then set columns it should not control (this is called over-posting). Instead, we add one class for incoming data with validation rules, and one record for outgoing data that also carries the country, state and city names.

The same file also contains the request and response types for the bulk delete endpoint. The request must contain between 1 and 500 employee IDs, and the response tells the caller how many rows were actually deleted.

Models/EmployeeDtos.cs

csharp
using System.ComponentModel.DataAnnotations;

namespace AngularCrudApi.Models;

public class EmployeeRequest
{
    [Required, StringLength(50)]
    public string FirstName { get; set; } = string.Empty;

    [Required, StringLength(50)]
    public string LastName { get; set; } = string.Empty;

    [Required]
    public DateOnly? DateOfBirth { get; set; }

    [Required, EmailAddress, StringLength(100)]
    public string EmailId { get; set; } = string.Empty;

    [Required, RegularExpression("^(Male|Female)$")]
    public string Gender { get; set; } = string.Empty;

    [Range(1, int.MaxValue)]
    public int CountryId { get; set; }

    [Range(1, int.MaxValue)]
    public int StateId { get; set; }

    [Range(1, int.MaxValue)]
    public int CityId { get; set; }

    [Required, StringLength(100)]
    public string Address { get; set; } = string.Empty;

    [Required, RegularExpression(@"^\d{6}$")]
    public string Pincode { get; set; } = string.Empty;
}

public record EmployeeResponse(
    int EmpId,
    string FirstName,
    string LastName,
    DateOnly DateOfBirth,
    string EmailId,
    string Gender,
    int CountryId,
    int StateId,
    int CityId,
    string Address,
    string Pincode,
    string Country,
    string State,
    string City);

public class DeleteEmployeesRequest
{
    [Required, MinLength(1), MaxLength(500)]
    public List<int> Ids { get; set; } = new();
}

public record DeleteEmployeesResponse(int DeletedCount);

Two of these rules are deliberate simplifications. The gender field accepts only Male and Female, so change the radio buttons and the regular expression together if you need other values.

The pincode rule (exactly six digits) matches Indian PIN codes. If you add countries with other postal code formats, change the rule on both the API and the Angular form.

Now create a Data folder and add the DbContext. It tells Entity Framework Core which table each class maps to. The key of Employee is named EmpId, which does not follow the default naming convention, so we declare it explicitly.

Data/AppDbContext.cs

csharp
using AngularCrudApi.Models;
using Microsoft.EntityFrameworkCore;

namespace AngularCrudApi.Data;

public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
    {
    }

    public DbSet<Employee> Employees => Set<Employee>();
    public DbSet<Country> Countries => Set<Country>();
    public DbSet<State> States => Set<State>();
    public DbSet<City> Cities => Set<City>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Employee>().ToTable("tblEmployeeMaster").HasKey(e => e.EmpId);
        modelBuilder.Entity<Country>().ToTable("CountryMaster");
        modelBuilder.Entity<State>().ToTable("StateMaster");
        modelBuilder.Entity<City>().ToTable("CityMaster");
    }
}

Step 4: Configure the Connection String, CORS and Program.cs

Open appsettings.json and add the connection string. The example uses Windows authentication on a local SQL Server. If you use SQL Server authentication, replace Trusted_Connection=True with User Id and Password values.

json
{
  "ConnectionStrings": {
    "DefaultConnection": "Server=localhost;Database=AngularCrudDB;Trusted_Connection=True;TrustServerCertificate=True;"
  }
}

Keep the other settings the template generated in this file. TrustServerCertificate=True is convenient for local development, but do not use it blindly for a production server.

Adjust the server name to match your installation. SQL Server Express usually runs as localhost\SQLEXPRESS and LocalDB as (localdb)\MSSQLLocalDB, and in appsettings.json each backslash must be written twice (\\).

Windows authentication (Trusted_Connection=True) works only on Windows, so on Linux or macOS use SQL Server authentication.

When the Angular app is served from http://localhost:4200 and calls an API on another port, the browser blocks the call unless the API allows that origin. This restriction is called CORS (Cross-Origin Resource Sharing).

An origin consists only of the scheme, host and port, so write it in WithOrigins without a path or a trailing slash (http://localhost:4200, not http://localhost:4200/).

Instead of allowing every origin, we allow only the Angular development server. Now update Program.cs. If the lines that the template generated for OpenAPI look slightly different in your version, keep the template's lines and add only the database and CORS parts.

csharp
using AngularCrudApi.Data;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));

builder.Services.AddCors(options =>
{
    options.AddPolicy("AngularClient", policy =>
        policy.WithOrigins("http://localhost:4200")
              .AllowAnyHeader()
              .AllowAnyMethod());
});

builder.Services.AddControllers();
builder.Services.AddOpenApi();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
}

app.UseHttpsRedirection();
app.UseCors("AngularClient");
app.UseAuthorization();
app.MapControllers();

app.Run();

The order matters: UseCors must come before UseAuthorization and MapControllers.

Step 5: Create the API Controller

Create Controllers/EmployeeController.cs. The controller uses async and await so the server thread is not blocked while SQL Server works, and it returns proper HTTP status codes such as 404 (not found), 400 (bad request), 201 (created) and 204 (no content).

Before saving, it also checks that the selected city really belongs to the selected state and country, so a client cannot store an inconsistent location. It also rejects a date of birth in the future.

The DeleteMultipleEmployees action handles bulk delete. It removes duplicate IDs and then runs a single DELETE statement with ExecuteDeleteAsync (available from EF Core 7).

Because it is one statement, either all matching rows are deleted or none are. IDs that no longer exist are simply ignored, and the response contains the number of rows that were really deleted.

csharp
using AngularCrudApi.Data;
using AngularCrudApi.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

namespace AngularCrudApi.Controllers;

[ApiController]
[Route("api/[controller]")]
public class EmployeeController : ControllerBase
{
    private readonly AppDbContext _context;

    public EmployeeController(AppDbContext context)
    {
        _context = context;
    }

    [HttpGet("AllEmployeeDetails")]
    public async Task<ActionResult<List<EmployeeResponse>>> GetAllEmployees()
    {
        var employees = await ToResponse(_context.Employees).ToListAsync();
        return Ok(employees);
    }

    [HttpGet("GetEmployeeDetailsById/{id:int}")]
    public async Task<ActionResult<EmployeeResponse>> GetEmployeeById(int id)
    {
        var employee = await ToResponse(_context.Employees.Where(e => e.EmpId == id)).FirstOrDefaultAsync();
        return employee is null ? NotFound() : Ok(employee);
    }

    [HttpPost("InsertEmployeeDetails")]
    public async Task<ActionResult<EmployeeResponse>> InsertEmployee(EmployeeRequest request)
    {
        var error = await ValidateAsync(request);
        if (error is not null)
        {
            return BadRequest(new { message = error });
        }

        var employee = new Employee();
        Apply(request, employee);

        _context.Employees.Add(employee);
        await _context.SaveChangesAsync();

        var response = await ToResponse(_context.Employees.Where(e => e.EmpId == employee.EmpId)).FirstAsync();
        return CreatedAtAction(nameof(GetEmployeeById), new { id = employee.EmpId }, response);
    }

    [HttpPut("UpdateEmployeeDetails/{id:int}")]
    public async Task<IActionResult> UpdateEmployee(int id, EmployeeRequest request)
    {
        var employee = await _context.Employees.FindAsync(id);
        if (employee is null)
        {
            return NotFound();
        }

        var error = await ValidateAsync(request);
        if (error is not null)
        {
            return BadRequest(new { message = error });
        }

        Apply(request, employee);
        await _context.SaveChangesAsync();

        return NoContent();
    }

    [HttpDelete("DeleteEmployeeDetails/{id:int}")]
    public async Task<IActionResult> DeleteEmployee(int id)
    {
        var employee = await _context.Employees.FindAsync(id);
        if (employee is null)
        {
            return NotFound();
        }

        _context.Employees.Remove(employee);
        await _context.SaveChangesAsync();

        return NoContent();
    }

    [HttpPost("DeleteMultipleEmployees")]
    public async Task<ActionResult<DeleteEmployeesResponse>> DeleteMultipleEmployees(DeleteEmployeesRequest request)
    {
        var ids = request.Ids.Distinct().ToList();

        var deletedCount = await _context.Employees
            .Where(e => ids.Contains(e.EmpId))
            .ExecuteDeleteAsync();

        return Ok(new DeleteEmployeesResponse(deletedCount));
    }

    [HttpGet("Country")]
    public async Task<ActionResult<List<Country>>> GetCountries()
    {
        var countries = await _context.Countries.AsNoTracking().OrderBy(c => c.CountryName).ToListAsync();
        return Ok(countries);
    }

    [HttpGet("Country/{countryId:int}/State")]
    public async Task<ActionResult<List<State>>> GetStates(int countryId)
    {
        var states = await _context.States.AsNoTracking()
            .Where(s => s.CountryId == countryId)
            .OrderBy(s => s.StateName)
            .ToListAsync();
        return Ok(states);
    }

    [HttpGet("State/{stateId:int}/City")]
    public async Task<ActionResult<List<City>>> GetCities(int stateId)
    {
        var cities = await _context.Cities.AsNoTracking()
            .Where(c => c.StateId == stateId)
            .OrderBy(c => c.CityName)
            .ToListAsync();
        return Ok(cities);
    }

    private IQueryable<EmployeeResponse> ToResponse(IQueryable<Employee> employees) =>
        from emp in employees.AsNoTracking()
        join country in _context.Countries on emp.CountryId equals country.CountryId
        join state in _context.States on emp.StateId equals state.StateId
        join city in _context.Cities on emp.CityId equals city.CityId
        orderby emp.EmpId
        select new EmployeeResponse(
            emp.EmpId, emp.FirstName, emp.LastName, emp.DateOfBirth, emp.EmailId, emp.Gender,
            emp.CountryId, emp.StateId, emp.CityId, emp.Address, emp.Pincode,
            country.CountryName, state.StateName, city.CityName);

    private async Task<string?> ValidateAsync(EmployeeRequest request)
    {
        if (request.DateOfBirth > DateOnly.FromDateTime(DateTime.Today))
        {
            return "Date of birth cannot be in the future.";
        }

        var locationIsValid = await _context.Cities.AnyAsync(c =>
            c.CityId == request.CityId && c.StateId == request.StateId && c.CountryId == request.CountryId);

        return locationIsValid ? null : "The selected country, state and city do not match.";
    }

    private static void Apply(EmployeeRequest request, Employee employee)
    {
        employee.FirstName = request.FirstName;
        employee.LastName = request.LastName;
        employee.DateOfBirth = request.DateOfBirth!.Value;
        employee.EmailId = request.EmailId;
        employee.Gender = request.Gender;
        employee.CountryId = request.CountryId;
        employee.StateId = request.StateId;
        employee.CityId = request.CityId;
        employee.Address = request.Address;
        employee.Pincode = request.Pincode;
    }
}

The private ToResponse method joins the employee rows with the country, state and city tables and projects them into EmployeeResponse.

It receives an already filtered query, so the WHERE condition on EmpId is part of the query before the projection is applied. This keeps the generated SQL simple and predictable.

Run the API with dotnet run --urls http://localhost:5000. The --urls option keeps the port fixed, so it matches the URL we use in the Angular service later. Without it, the template's launch profile chooses its own port, and you must use the URL printed in the console instead.

With this HTTP-only URL, the console may print a warning that the HTTPS redirect port could not be determined. You can ignore it during local development.

On macOS, port 5000 is often already used by the AirPlay Receiver service. If the API fails to start because the port is busy, use another port (for example 5050) here and in the Angular service.

To test the API, open /api/Employee/Country on that URL in the browser. You should see the countries you inserted, as JSON.

Note that ASP.NET Core writes JSON property names in camelCase by default (for example firstName), so our Angular models will use camelCase too.

Step 6: Create the Angular Project

The API is ready, so now we build the Angular part. First install the Angular CLI. If it is already installed, you can skip this command.

powershell
npm install -g @angular/cli

Now create the project, move into its folder and add Angular Material. When ng new asks questions, you can accept the defaults, and choose No if it asks about server-side rendering.

ng add @angular/material installs Material and its CDK and configures a theme for you, so you do not need to import a theme file by hand.

powershell
ng new angular-crud
cd angular-crud
ng add @angular/material

If you want to update an existing Angular project instead, run the following commands, and update one major version at a time. Follow the steps in the Angular Update Guide.

Avoid the --force flag unless you understand which dependency conflict it is hiding.

powershell
ng update @angular/core @angular/cli
ng update @angular/material

Angular Material's theme options are described in the Material theming guide.

Step 7: Generate the Component and the Service

Generate a component and a service, both named employee.

powershell
ng generate component employee --type=component --skip-tests
ng generate service employee --type=service --skip-tests

In Angular 20 and later, the CLI no longer adds the Component and Service suffixes by default, so without the --type option you would get employee.ts with a class named Employee.

Using --type keeps the classic file names used in this article: src/app/employee/employee.component.ts (plus its .html and .css files) and src/app/employee.service.ts.

The --skip-tests option skips the generated .spec.ts files, because this tutorial does not cover unit tests.

The spec file that ng new created for the root component (src/app/app.spec.ts) targets the default template, so it may fail after Step 14. Update or delete it if you run ng test.

Step 8: Create the Models

Create a new file src/app/employee.model.ts with the interfaces below. The property names match the JSON returned by the API. The date is a plain yyyy-MM-dd string, which avoids time zone problems.

typescript
export interface Employee {
  empId: number;
  firstName: string;
  lastName: string;
  dateOfBirth: string;
  emailId: string;
  gender: string;
  countryId: number;
  stateId: number;
  cityId: number;
  address: string;
  pincode: string;
  country: string;
  state: string;
  city: string;
}

export type EmployeeRequest = Omit<Employee, 'empId' | 'country' | 'state' | 'city'>;

export interface DeleteEmployeesResult {
  deletedCount: number;
}

export interface Country {
  countryId: number;
  countryName: string;
}

export interface State {
  stateId: number;
  stateName: string;
  countryId: number;
}

export interface City {
  cityId: number;
  cityName: string;
  stateId: number;
  countryId: number;
}

Step 9: Configure HttpClient

New Angular projects no longer use an AppModule, and dependencies are registered as providers. In Angular 21 and later, HttpClient is already available in the root injector, so this step is optional there. Adding it explicitly is harmless and works in every version.

It is still worth knowing, because you need provideHttpClient() in Angular 20 and earlier, and whenever you want to customize the client (for example, to register interceptors).

To register it explicitly, open src/app/app.config.ts and add provideHttpClient() to the providers array, keeping the providers the CLI generated:

typescript
import { provideHttpClient } from '@angular/common/http';

export const appConfig: ApplicationConfig = {
  providers: [
    ...,
    provideHttpClient(),
  ],
};

Here ... stands for the providers that are already in your file.

Some older tutorials also add provideAnimationsAsync() for Angular Material. Angular has deprecated that function since version 20.2, and recent Angular Material versions (21 and later) no longer need an animations provider, so this tutorial does not use it.

If you are on an older Material version that still requires it, add it to the same providers array.

Step 10: Write the Angular Service

Open src/app/employee.service.ts and replace its content with the code below. It contains one method for each API endpoint.

Change apiUrl if your API does not run on http://localhost:5000. Use the URL and port that dotnet run printed, followed by /api/Employee.

typescript
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { City, Country, DeleteEmployeesResult, Employee, EmployeeRequest, State } from './employee.model';

@Injectable({ providedIn: 'root' })
export class EmployeeService {
  private readonly http = inject(HttpClient);
  private readonly apiUrl = 'http://localhost:5000/api/Employee';

  getAllEmployees(): Observable<Employee[]> {
    return this.http.get<Employee[]>(`${this.apiUrl}/AllEmployeeDetails`);
  }

  getEmployeeById(empId: number): Observable<Employee> {
    return this.http.get<Employee>(`${this.apiUrl}/GetEmployeeDetailsById/${empId}`);
  }

  createEmployee(employee: EmployeeRequest): Observable<Employee> {
    return this.http.post<Employee>(`${this.apiUrl}/InsertEmployeeDetails`, employee);
  }

  updateEmployee(empId: number, employee: EmployeeRequest): Observable<void> {
    return this.http.put<void>(`${this.apiUrl}/UpdateEmployeeDetails/${empId}`, employee);
  }

  deleteEmployee(empId: number): Observable<void> {
    return this.http.delete<void>(`${this.apiUrl}/DeleteEmployeeDetails/${empId}`);
  }

  deleteEmployees(empIds: number[]): Observable<DeleteEmployeesResult> {
    return this.http.post<DeleteEmployeesResult>(`${this.apiUrl}/DeleteMultipleEmployees`, { ids: empIds });
  }

  getCountries(): Observable<Country[]> {
    return this.http.get<Country[]>(`${this.apiUrl}/Country`);
  }

  getStates(countryId: number): Observable<State[]> {
    return this.http.get<State[]>(`${this.apiUrl}/Country/${countryId}/State`);
  }

  getCities(stateId: number): Observable<City[]> {
    return this.http.get<City[]>(`${this.apiUrl}/State/${stateId}/City`);
  }
}

The service uses the inject() function to get HttpClient, which is the modern replacement for constructor parameters.

Step 11: Write the Component Class

Open src/app/employee/employee.component.ts and replace its content with the code below.

The component is standalone: it lists the Material modules it needs directly in its imports array, so no NgModule is required. The lists for the cascading dropdown and the set of selected rows are stored in Angular signals, and the table uses a MatTableDataSource, which provides searching, sorting and pagination.

Keeping the component's own state in signals also fits Angular 22, where components use OnPush change detection by default.

typescript
import { formatDate } from '@angular/common';
import { HttpErrorResponse } from '@angular/common/http';
import { AfterViewInit, Component, OnInit, computed, inject, signal, viewChild } from '@angular/core';
import { FormGroupDirective, NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { provideNativeDateAdapter } from '@angular/material/core';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatPaginator, MatPaginatorModule } from '@angular/material/paginator';
import { MatRadioModule } from '@angular/material/radio';
import { MatSelectModule } from '@angular/material/select';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatSort, MatSortModule } from '@angular/material/sort';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { MatToolbarModule } from '@angular/material/toolbar';
import { Observable, forkJoin, map, switchMap } from 'rxjs';
import { EmployeeService } from '../employee.service';
import { City, Country, Employee, EmployeeRequest, State } from '../employee.model';

@Component({
  selector: 'app-employee',
  imports: [
    ReactiveFormsModule,
    MatButtonModule,
    MatCardModule,
    MatCheckboxModule,
    MatDatepickerModule,
    MatFormFieldModule,
    MatIconModule,
    MatInputModule,
    MatPaginatorModule,
    MatRadioModule,
    MatSelectModule,
    MatSortModule,
    MatTableModule,
    MatToolbarModule,
  ],
  providers: [provideNativeDateAdapter()],
  templateUrl: './employee.component.html',
  styleUrl: './employee.component.css',
})
export class EmployeeComponent implements OnInit, AfterViewInit {
  private readonly fb = inject(NonNullableFormBuilder);
  private readonly employeeService = inject(EmployeeService);
  private readonly snackBar = inject(MatSnackBar);

  private readonly formDirective = viewChild.required(FormGroupDirective);
  private readonly paginator = viewChild.required(MatPaginator);
  private readonly sort = viewChild.required(MatSort);

  readonly countries = signal<Country[]>([]);
  readonly states = signal<State[]>([]);
  readonly cities = signal<City[]>([]);
  readonly editingId = signal<number | null>(null);
  readonly today = new Date();

  readonly selectedIds = signal<ReadonlySet<number>>(new Set<number>());
  readonly selectedCount = computed(() => this.selectedIds().size);

  readonly dataSource = new MatTableDataSource<Employee>([]);
  readonly columns: { key: keyof Employee; label: string }[] = [
    { key: 'firstName', label: 'First Name' },
    { key: 'lastName', label: 'Last Name' },
    { key: 'dateOfBirth', label: 'Date of Birth' },
    { key: 'emailId', label: 'Email' },
    { key: 'gender', label: 'Gender' },
    { key: 'country', label: 'Country' },
    { key: 'state', label: 'State' },
    { key: 'city', label: 'City' },
    { key: 'address', label: 'Address' },
    { key: 'pincode', label: 'Pincode' },
  ];
  readonly displayedColumns = ['select', ...this.columns.map((column) => column.key), 'actions'];

  readonly employeeForm = this.fb.group({
    firstName: ['', [Validators.required, Validators.maxLength(50)]],
    lastName: ['', [Validators.required, Validators.maxLength(50)]],
    dateOfBirth: [null as Date | null, Validators.required],
    emailId: ['', [Validators.required, Validators.email]],
    gender: ['Male', Validators.required],
    countryId: [null as number | null, Validators.required],
    stateId: [null as number | null, Validators.required],
    cityId: [null as number | null, Validators.required],
    address: ['', [Validators.required, Validators.maxLength(100)]],
    pincode: ['', [Validators.required, Validators.pattern(/^\d{6}$/)]],
  });

  ngOnInit(): void {
    this.dataSource.filterPredicate = (row, filter) =>
      this.columns.some((column) => String(row[column.key]).toLowerCase().includes(filter));

    this.employeeService.getCountries().subscribe({
      next: (countries) => this.countries.set(countries),
      error: () => this.showMessage('Unable to load countries.'),
    });
    this.loadEmployees();
  }

  ngAfterViewInit(): void {
    this.dataSource.paginator = this.paginator();
    this.dataSource.sort = this.sort();
  }

  loadEmployees(): void {
    this.employeeService.getAllEmployees().subscribe({
      next: (employees) => {
        this.dataSource.data = employees;

        const existingIds = new Set(employees.map((employee) => employee.empId));
        this.selectedIds.update((ids) => new Set([...ids].filter((id) => existingIds.has(id))));
      },
      error: () => this.showMessage('Unable to load employees. Is the API running?'),
    });
  }

  applyFilter(event: Event): void {
    const value = (event.target as HTMLInputElement).value;
    this.dataSource.filter = value.trim().toLowerCase();
    this.dataSource.paginator?.firstPage();
    this.selectedIds.set(new Set<number>());
  }

  isSelected(empId: number): boolean {
    return this.selectedIds().has(empId);
  }

  toggleRow(empId: number): void {
    const next = new Set(this.selectedIds());
    if (next.has(empId)) {
      next.delete(empId);
    } else {
      next.add(empId);
    }
    this.selectedIds.set(next);
  }

  isAllSelected(): boolean {
    const rows = this.dataSource.filteredData;
    return rows.length > 0 && rows.every((row) => this.selectedIds().has(row.empId));
  }

  isSomeSelected(): boolean {
    return this.selectedCount() > 0 && !this.isAllSelected();
  }

  toggleAll(): void {
    if (this.isAllSelected()) {
      this.selectedIds.set(new Set<number>());
    } else {
      this.selectedIds.set(new Set(this.dataSource.filteredData.map((row) => row.empId)));
    }
  }

  deleteSelected(): void {
    const ids = [...this.selectedIds()];
    if (ids.length === 0) {
      return;
    }

    const message =
      ids.length === 1
        ? 'Are you sure you want to delete the selected employee?'
        : `Are you sure you want to delete ${ids.length} selected employees?`;
    if (!confirm(message)) {
      return;
    }

    this.employeeService.deleteEmployees(ids).subscribe({
      next: ({ deletedCount }) => {
        this.showMessage(`${deletedCount} record(s) deleted successfully!`);

        const editing = this.editingId();
        if (editing !== null && ids.includes(editing)) {
          this.resetForm();
        }

        this.selectedIds.set(new Set<number>());
        this.loadEmployees();
      },
      error: () => this.showMessage('Unable to delete the selected records.'),
    });
  }

  onCountryChange(countryId: number): void {
    this.employeeForm.patchValue({ stateId: null, cityId: null });
    this.states.set([]);
    this.cities.set([]);
    this.employeeService.getStates(countryId).subscribe({
      next: (states) => this.states.set(states),
      error: () => this.showMessage('Unable to load states.'),
    });
  }

  onStateChange(stateId: number): void {
    this.employeeForm.patchValue({ cityId: null });
    this.cities.set([]);
    this.employeeService.getCities(stateId).subscribe({
      next: (cities) => this.cities.set(cities),
      error: () => this.showMessage('Unable to load cities.'),
    });
  }

  onSubmit(): void {
    if (this.employeeForm.invalid) {
      this.employeeForm.markAllAsTouched();
      return;
    }

    const { dateOfBirth, countryId, stateId, cityId, ...rest } = this.employeeForm.getRawValue();
    if (!dateOfBirth || countryId === null || stateId === null || cityId === null) {
      return;
    }

    const request: EmployeeRequest = {
      ...rest,
      dateOfBirth: formatDate(dateOfBirth, 'yyyy-MM-dd', 'en-US'),
      countryId,
      stateId,
      cityId,
    };

    const id = this.editingId();
    const save$: Observable<unknown> =
      id === null
        ? this.employeeService.createEmployee(request)
        : this.employeeService.updateEmployee(id, request);

    save$.subscribe({
      next: () => {
        this.showMessage(id === null ? 'Record saved successfully!' : 'Record updated successfully!');
        this.resetForm();
        this.loadEmployees();
      },
      error: (error: HttpErrorResponse) =>
        this.showMessage(error.error?.message ?? 'Unable to save the record. Please check the values and try again.'),
    });
  }

  editEmployee(empId: number): void {
    this.employeeService
      .getEmployeeById(empId)
      .pipe(
        switchMap((employee) =>
          forkJoin({
            states: this.employeeService.getStates(employee.countryId),
            cities: this.employeeService.getCities(employee.stateId),
          }).pipe(map((lists) => ({ employee, ...lists }))),
        ),
      )
      .subscribe({
        next: ({ employee, states, cities }) => {
          const [year, month, day] = employee.dateOfBirth.split('-').map(Number);

          this.states.set(states);
          this.cities.set(cities);
          this.editingId.set(employee.empId);
          this.employeeForm.patchValue({
            firstName: employee.firstName,
            lastName: employee.lastName,
            dateOfBirth: new Date(year, month - 1, day),
            emailId: employee.emailId,
            gender: employee.gender,
            countryId: employee.countryId,
            stateId: employee.stateId,
            cityId: employee.cityId,
            address: employee.address,
            pincode: employee.pincode,
          });
        },
        error: () => this.showMessage('Unable to load the selected employee.'),
      });
  }

  deleteEmployee(empId: number): void {
    if (!confirm('Are you sure you want to delete this employee?')) {
      return;
    }

    this.employeeService.deleteEmployee(empId).subscribe({
      next: () => {
        this.showMessage('Record deleted successfully!');
        if (this.editingId() === empId) {
          this.resetForm();
        }
        this.loadEmployees();
      },
      error: () => this.showMessage('Unable to delete the record.'),
    });
  }

  resetForm(): void {
    this.formDirective().resetForm();
    this.editingId.set(null);
    this.states.set([]);
    this.cities.set([]);
  }

  private showMessage(message: string): void {
    this.snackBar.open(message, 'Close', { duration: 3000 });
  }
}

A few points in this code are worth understanding:

  • Dates: The date picker returns a JavaScript Date. Sending it directly would serialize it as a UTC timestamp, and in time zones ahead of UTC (such as India) the saved date can end up one day earlier.
  • Date handling in this code: formatDate sends a plain yyyy-MM-dd string, and when editing we build the Date from its year, month and day parts instead of parsing the string.
  • Date input: the date picker uses Material's native date adapter, which shows dates in the US format (MM/DD/YYYY) by default. The [max] binding blocks future dates in the browser, and the API checks this again.
  • Typing a date: when a user types a date, the adapter relies on the browser's built-in date parsing, so an ambiguous value such as 05/06/2000 is read as month/day. The hint under the field states the expected format, and the calendar button avoids the problem completely.
  • Other date formats: if you need a different format, use a different date adapter (the Material documentation describes adapters for libraries such as date-fns, Luxon and Moment).
  • Cascading dropdown: When the country changes, the selected state and city are cleared, and the state list is loaded for that country. The same happens for the city list when the state changes.
  • Editing with a cascading dropdown: when editing, forkJoin loads both lists in parallel before the form is filled, so the saved values can be selected.
  • Error messages: when saving fails, the component shows the message returned by the API if there is one (for example, when the location does not match), and a generic message otherwise. The country, state and city lists also show a message if they cannot be loaded.
  • Reset: resetForm() calls FormGroupDirective.resetForm() rather than form.reset(). This also clears the "submitted" state, otherwise Material would show validation errors on every empty field right after a successful save.
  • Table columns: the column key must match the property name in the data (for example firstName), because that is how MatSort finds the value to sort by. Typing the key as keyof Employee lets TypeScript catch a misspelled name.
  • Search: by default, MatTableDataSource compares the search text with every property of a row, including hidden ones such as countryId. Searching for "2" would then match rows because of an internal ID.
  • Search in this code: the custom filterPredicate in ngOnInit checks only the columns the user can see.
  • Bulk delete selection: the selected rows are stored as a set of employee IDs (not row objects) in a signal, so the selection survives a table reload. After each reload, IDs that no longer exist are removed from the selection.
  • Select all: the header checkbox selects every row that matches the current search, including rows on other pages.
  • Bulk delete safety: changing the search text clears the selection, so you can never delete rows that are hidden by a filter. The button shows how many rows are selected, is disabled when nothing is selected, and the confirmation message repeats the count.
  • Deleting the row being edited: if the row being edited is deleted, the form is reset.

Step 12: Write the Component Template

Open src/app/employee/employee.component.html and replace its content with the following. It uses Angular's built-in control flow (@if and @for), which replaces the older *ngIf and *ngFor directives and needs no extra imports.

The text columns of the table are generated with one @for loop from the columns array we defined in the class. The first column holds the checkboxes for bulk delete.

The search box listens to the input event instead of keyup, so pressing keys such as Tab or the arrow keys does not clear the selection when the text has not changed.

html
<div class="container">
  <mat-card>
    <mat-toolbar>
      <span>Angular CRUD Example with ASP.NET Core Web API</span>
    </mat-toolbar>

    <mat-card-content>
      <form class="employee-form" [formGroup]="employeeForm" (ngSubmit)="onSubmit()">
        <mat-form-field>
          <mat-label>First name</mat-label>
          <input matInput formControlName="firstName" autocomplete="off" />
          @if (employeeForm.controls.firstName.hasError('required')) {
            <mat-error>First name is required</mat-error>
          }
        </mat-form-field>

        <mat-form-field>
          <mat-label>Last name</mat-label>
          <input matInput formControlName="lastName" autocomplete="off" />
          @if (employeeForm.controls.lastName.hasError('required')) {
            <mat-error>Last name is required</mat-error>
          }
        </mat-form-field>

        <mat-form-field>
          <mat-label>Date of birth</mat-label>
          <input matInput [matDatepicker]="picker" [max]="today" formControlName="dateOfBirth" autocomplete="off" />
          <mat-datepicker-toggle matIconSuffix [for]="picker" />
          <mat-datepicker #picker />
          <mat-hint>MM/DD/YYYY</mat-hint>
          @if (employeeForm.controls.dateOfBirth.hasError('matDatepickerParse')) {
            <mat-error>Enter a valid date</mat-error>
          } @else if (employeeForm.controls.dateOfBirth.hasError('required')) {
            <mat-error>Date of birth is required</mat-error>
          } @else if (employeeForm.controls.dateOfBirth.hasError('matDatepickerMax')) {
            <mat-error>Date of birth cannot be in the future</mat-error>
          }
        </mat-form-field>

        <mat-form-field>
          <mat-label>Email</mat-label>
          <input matInput formControlName="emailId" autocomplete="off" />
          @if (employeeForm.controls.emailId.hasError('required')) {
            <mat-error>Email is required</mat-error>
          } @else if (employeeForm.controls.emailId.hasError('email')) {
            <mat-error>Enter a valid email address</mat-error>
          }
        </mat-form-field>

        <div class="gender-field">
          <span>Gender</span>
          <mat-radio-group formControlName="gender">
            <mat-radio-button value="Male">Male</mat-radio-button>
            <mat-radio-button value="Female">Female</mat-radio-button>
          </mat-radio-group>
        </div>

        <mat-form-field>
          <mat-label>Country</mat-label>
          <mat-select formControlName="countryId" (selectionChange)="onCountryChange($event.value)">
            @for (country of countries(); track country.countryId) {
              <mat-option [value]="country.countryId">{{ country.countryName }}</mat-option>
            }
          </mat-select>
          @if (employeeForm.controls.countryId.hasError('required')) {
            <mat-error>Select a country</mat-error>
          }
        </mat-form-field>

        <mat-form-field>
          <mat-label>State</mat-label>
          <mat-select formControlName="stateId" (selectionChange)="onStateChange($event.value)">
            @for (state of states(); track state.stateId) {
              <mat-option [value]="state.stateId">{{ state.stateName }}</mat-option>
            }
          </mat-select>
          @if (employeeForm.controls.stateId.hasError('required')) {
            <mat-error>Select a state</mat-error>
          }
        </mat-form-field>

        <mat-form-field>
          <mat-label>City</mat-label>
          <mat-select formControlName="cityId">
            @for (city of cities(); track city.cityId) {
              <mat-option [value]="city.cityId">{{ city.cityName }}</mat-option>
            }
          </mat-select>
          @if (employeeForm.controls.cityId.hasError('required')) {
            <mat-error>Select a city</mat-error>
          }
        </mat-form-field>

        <mat-form-field>
          <mat-label>Address</mat-label>
          <input matInput formControlName="address" autocomplete="off" />
          @if (employeeForm.controls.address.hasError('required')) {
            <mat-error>Address is required</mat-error>
          }
        </mat-form-field>

        <mat-form-field>
          <mat-label>Pincode</mat-label>
          <input matInput formControlName="pincode" maxlength="6" autocomplete="off" />
          <mat-hint>6 digits (for example, 380001)</mat-hint>
          @if (employeeForm.controls.pincode.invalid) {
            <mat-error>Enter a 6-digit pincode</mat-error>
          }
        </mat-form-field>

        <div class="actions">
          <button mat-flat-button type="submit">{{ editingId() === null ? 'Save' : 'Update' }}</button>
          <button mat-stroked-button type="button" (click)="resetForm()">Reset</button>
        </div>
      </form>

      <div class="table-toolbar">
        <mat-form-field class="filter-field">
          <mat-label>Search</mat-label>
          <input matInput (input)="applyFilter($event)" placeholder="Type to filter the table" />
        </mat-form-field>

        <button mat-flat-button type="button" [disabled]="selectedCount() === 0" (click)="deleteSelected()">
          <mat-icon>delete</mat-icon>
          Delete selected ({{ selectedCount() }})
        </button>
      </div>

      <div class="table-container">
        <table mat-table [dataSource]="dataSource" matSort>
          <ng-container matColumnDef="select">
            <th mat-header-cell *matHeaderCellDef>
              <mat-checkbox
                aria-label="Select all matching employees"
                [checked]="isAllSelected()"
                [indeterminate]="isSomeSelected()"
                (change)="toggleAll()" />
            </th>
            <td mat-cell *matCellDef="let row">
              <mat-checkbox
                [aria-label]="'Select ' + row.firstName + ' ' + row.lastName"
                [checked]="isSelected(row.empId)"
                (change)="toggleRow(row.empId)" />
            </td>
          </ng-container>

          @for (column of columns; track column.key) {
            <ng-container [matColumnDef]="column.key">
              <th mat-header-cell *matHeaderCellDef mat-sort-header>{{ column.label }}</th>
              <td mat-cell *matCellDef="let row">{{ row[column.key] }}</td>
            </ng-container>
          }

          <ng-container matColumnDef="actions">
            <th mat-header-cell *matHeaderCellDef>Actions</th>
            <td mat-cell *matCellDef="let row">
              <button mat-icon-button type="button" aria-label="Edit employee" (click)="editEmployee(row.empId)">
                <mat-icon>edit</mat-icon>
              </button>
              <button mat-icon-button type="button" aria-label="Delete employee" (click)="deleteEmployee(row.empId)">
                <mat-icon>delete</mat-icon>
              </button>
            </td>
          </ng-container>

          <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
          <tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
          <tr class="mat-row" *matNoDataRow>
            <td class="mat-cell" [attr.colspan]="displayedColumns.length">No matching records found.</td>
          </tr>
        </table>
      </div>

      <mat-paginator [pageSizeOptions]="[5, 10, 25, 100]" showFirstLastButtons></mat-paginator>
    </mat-card-content>
  </mat-card>
</div>

Every required field shows an error message when you try to submit without a value. The date field also reports an invalid date or a date in the future.

If the edit and delete icons appear as plain words such as "edit", the Material Icons font is not loaded. Check that the font link added by ng add @angular/material is present in src/index.html. It usually looks like this:

html
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">

Step 13: Add the Styles

Open src/app/employee/employee.component.css and add the following. It uses a simple responsive grid for the form, so no inline styles are needed in the template.

css
.container {
  max-width: 1200px;
  margin: 24px auto;
  padding: 0 16px;
}

.employee-form {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: 8px 16px;
  padding-top: 16px;
}

.gender-field,
.actions {
  grid-column: 1 / -1;
  display: flex;
  align-items: center;
  gap: 16px;
}

.table-toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 16px;
  margin-top: 24px;
}

.filter-field {
  flex: 1 1 260px;
}

.table-container {
  overflow-x: auto;
}

table {
  width: 100%;
}

If you chose SCSS or another stylesheet format in ng new, put the same rules in the matching file, and update styleUrl in the component.

Step 14: Show the Component in the Root Component

In Angular 20 and later, a new project's root component is the class App in src/app/app.ts, with its template in src/app/app.html.

Projects created with Angular 19 or earlier use AppComponent in app.component.ts and app.component.html. The code is the same, only the file and class names differ.

Replace the content of src/app/app.ts with:

typescript
import { Component } from '@angular/core';
import { EmployeeComponent } from './employee/employee.component';

@Component({
  selector: 'app-root',
  imports: [EmployeeComponent],
  templateUrl: './app.html',
  styleUrl: './app.css',
})
export class App {}

Then replace the content of src/app/app.html with a single line:

html
<app-employee />

Step 15: Build and Run the Application

Now we have both projects. First start the Web API from its folder, and keep it running:

powershell
dotnet run --urls http://localhost:5000

Then, in the Angular project folder, build the project once to check for errors, and start the development server:

powershell
ng build
ng serve --open

The application opens at http://localhost:4200/. Try adding an employee, editing and deleting a row, changing the country and state to see the cascading dropdown, and using the search box, the column headers and the paginator.

To test bulk delete, add a few employees, tick two or more checkboxes and click Delete selected. Then try the header checkbox, which selects every row that matches the current search (not only the rows on the current page), and confirm that typing in the search box clears the selection.

Common Problems

  • A CORS error appears in the browser console: the origin in WithOrigins must match the address of the Angular app exactly, including the scheme and the port (http://localhost:4200). Restart the API after you change Program.cs.
  • The table shows "Unable to load employees. Is the API running?": check that apiUrl in the Angular service uses the same port as the running API, and open /api/Employee/Country in the browser to confirm that the API responds. If you use an https address, trust the development certificate first with dotnet dev-certs https --trust.
  • The API does not start because the port is busy: another program is using it. On macOS, port 5000 is commonly taken by AirPlay Receiver, so choose a different port and update apiUrl in the Angular service to match.
  • The API cannot connect to SQL Server: check the server name in the connection string, and remember that Windows authentication works only on Windows (see Step 4).
  • Saving returns a 400 error: the API validates every field. A pincode that is not exactly 6 digits, an invalid email address, a date of birth in the future, or a city that does not belong to the selected state and country will all be rejected.

Before Using This in Production

This is a learning example, and a real application needs more:

  • Authentication and authorization: the API is open to anyone who can reach it. This matters even more for bulk delete, because one request can remove up to 500 records.
  • Deleting data: both delete actions permanently remove the rows, and there is no undo. For real data, consider a soft delete (an IsDeleted flag) and an audit trail.
  • Bulk delete limit: the limit of 500 IDs per request keeps a single DELETE statement small and predictable. Choose a value that suits your own data, and split larger deletes into several requests.
  • Bulk delete internals: ExecuteDeleteAsync runs directly in SQL Server. It bypasses EF Core's change tracker and any SaveChanges interceptors, so soft-delete or audit logic that lives there will not run.
  • Related tables: ExecuteDeleteAsync is a single atomic statement, but if you later add tables that reference employees, configure cascade delete or remove the child rows first, otherwise the foreign key will block the delete.
  • Concurrent edits: if two users edit the same employee, the last save silently wins. A concurrency token (such as a rowversion column) lets the API detect this and return a conflict.
  • Server-side paging: the table loads every employee and pages in the browser. That is fine for a few thousand rows, but a large table should be paged, sorted and filtered on the server.
  • Duplicate records: nothing stops two employees from having the same email address. Add a unique index in SQL Server and handle the error in the API if your data requires it.
  • Configuration: keep the API URL in an environment-specific setting instead of a constant, and use HTTPS and your real domain in WithOrigins.
  • Error handling: add a global exception handler (for example AddProblemDetails) on the API, and logging.
  • Confirmation dialogs: the browser confirm() box is simple but basic. Angular Material's dialog gives a better experience.

Summary

In this article, we implemented a CRUD application with Angular and an ASP.NET Core Web API on top of a SQL Server database. We also used a date picker, radio buttons and a cascading dropdown, and added searching, sorting, pagination and multi-select bulk delete to an Angular Material table with standalone components, signals and the built-in control flow.

Please leave your comments if you have any questions, and share a link to this article if you like it.

Codingvila provides articles and blogs on web and software development for beginners as well as free Academic projects for final year students in Asp.Net, MVC, C#, Vb.Net, SQL Server, Angular Js, Android, PHP, Java, Python, Desktop Software Application and etc.

If you have any questions, contact us on info.codingvila@gmail.com