Build a REST API with ASP.NET Core

Build a CRUD REST API for industrial asset and maintenance management.

Introduction

30 Second Summary

Every factory floor has hundreds of machines that need regular maintenance. Without a system to track what equipment exists and when it was last serviced, things break down unexpectedly. The backend system that solves this problem is exactly the kind of API that .NET developers build every day.

In this project, you will build a REST API that manages industrial equipment and their maintenance schedules using ASP.NET Core, Entity Framework Core, and SQL Server.

What You'll Build

A production-style ASP.NET Core Web API that manages industrial assets (pumps, motors, conveyors) and their maintenance work orders, complete with interactive Swagger documentation for live testing.

By the end of this project, you'll have:

  • An ASP.NET Core 9 Web API with full CRUD operations for industrial assets and maintenance work orders.
  • A SQL Server database created via EF Core code-first migrations with proper relational modeling.
  • Interactive OpenAPI/Swagger documentation that demonstrates the API to anyone who opens it.
  • Secret Mission: Add filtering, search, and pagination to the Assets endpoint.

Are there any prerequisites?

No prior experience with ASP.NET Core or Entity Framework Core is needed. This guide walks you through installing every tool from scratch. You do need a Windows machine, since SQL Server LocalDB is Windows-only.

Before We Start

To build your Industrial Asset & Maintenance Management API, you need three tools installed on your Windows machine.

The .NET 9 SDK provides the compiler and runtime for your C# code. VS Code with the C# Dev Kit extension gives you a code editor with IntelliSense and debugging. SQL Server Express LocalDB provides a lightweight database that starts on demand with zero configuration.

In this step, get ready to:
  • Install and verify the .NET 9 SDK.
  • Set up VS Code with the C# Dev Kit extension.
  • Install SQL Server Express with the LocalDB feature.
Install the .NET 9 SDK

The .NET SDK includes everything you need to build and run C# applications from the command line.

  • Press the Windows key to open your search bar.
  • Type Terminal and press Enter to open it.
  • Install the .NET 9 SDK by running this command:
winget install Microsoft.DotNet.SDK.9

What does this command do?

winget is the Windows Package Manager. It downloads and installs the .NET 9 SDK directly from Microsoft's servers. This gives you the dotnet command-line tool for creating projects, restoring packages, and running your code.

  • The installer may ask you to accept license agreements. Enter Y to accept.
  • Once the install finishes, close and reopen your terminal so it picks up the new dotnet command.
  • Verify the installation by running:
dotnet --version

✔️ I see version 9.x

Your .NET 9 SDK is ready. The exact patch number (like 9.0.306) may differ, and that is fine as long as it starts with 9.

ⓧ I see an older version

You have an older .NET SDK installed (such as .NET 6 or .NET 8). .NET supports side-by-side installations, so you can install .NET 9 alongside your existing version.

  • Run the same install command:
winget install Microsoft.DotNet.SDK.9
  • Close and reopen your terminal.
  • Run dotnet --version again. It should now show a 9.x version.

ⓧ Command not found

The dotnet command is not recognized because the SDK is not installed yet or your terminal does not see it.

  • Make sure you closed and reopened your terminal after the winget install completed.
  • If winget itself is not recognized, download the .NET 9 SDK installer directly from the official .NET download page.
  • Run the downloaded installer, then close and reopen your terminal.
  • Run dotnet --version to confirm.
Install VS Code and C# Dev Kit

VS Code is a lightweight code editor. The C# Dev Kit extension adds IntelliSense, debugging, and project management for .NET projects.

  • Install VS Code by running this command in your terminal:
winget install --id=Microsoft.VisualStudioCode -e

✔️ It installed successfully

VS Code is now installed. Move on to installing the C# Dev Kit extension below.

ⓧ I already have VS Code

If winget reports that VS Code is already installed, you are all set. Skip to installing the C# Dev Kit extension below.

  • Press the Windows key to open your search bar.
  • Type Visual Studio Code and press Enter to open it.
  • Open the Extensions panel by pressing Ctrl+Shift+X.
  • Type C# Dev Kit in the search bar.
  • Click Install on the extension published by Microsoft.

What does C# Dev Kit include?

C# Dev Kit bundles the C# language server, the .NET debugger, and a project system into one extension. It gives you code completion, error highlighting, and the ability to run and debug your API directly from VS Code.

  • Wait for the extension to finish installing. You should see a green checkmark next to C# Dev Kit in the Extensions panel.

Extension not appearing in search?

Make sure you are searching the Extensions Marketplace (the search bar at the top of the Extensions panel), not the main VS Code search. If results are empty, check your internet connection and try again.

help me find and install the C# Dev Kit extension in VS Code

Install SQL Server Express LocalDB

SQL Server Express LocalDB is a developer-focused version of SQL Server that runs in user mode. It starts automatically when your application connects and shuts down when idle.

  • Download the SQL Server 2022 Express installer from the Microsoft Download Center.
  • Click Download to get the SQL2022-SSEI-Expr.exe file.
  • Run the downloaded file. The installer presents three options.
  • Select Custom to choose which features to install.

Why Custom instead of Basic?

The Basic option installs only the database engine. Choosing Custom lets you select LocalDB on the Feature Selection page, which is the lightweight option this project uses.

  • Accept the license terms and choose a download location for the setup files.
  • When the SQL Server Installation Center opens, click New SQL Server stand-alone installation.
  • On the Feature Selection page, scroll down to the Shared Features section.
  • Check the box for LocalDB.
  • Click through the remaining pages accepting defaults, then click Install.
  • Wait for the installation to complete. This may take a few minutes.
  • Open a new terminal window and verify LocalDB is installed by running:
sqllocaldb info

✔️ I see MSSQLLocalDB listed

LocalDB is installed and ready. The MSSQLLocalDB instance is the default instance your project will connect to.

ⓧ Command not found

The sqllocaldb command is not recognized. This means LocalDB was not installed or your terminal cannot find it.

  • Make sure you selected LocalDB under Shared Features during the Custom installation.
  • If you accidentally chose Basic, re-run the SQL2022-SSEI-Expr.exe installer and select Custom this time.
  • Close and reopen your terminal after installation completes.
  • Run sqllocaldb info again.

help me troubleshoot why sqllocaldb is not recognized after installing SQL Server Express

Your development environment is ready. Next up, you'll scaffold the ASP.NET Core Web API project and define the domain models that represent industrial assets and maintenance work orders.

Create the Project and Define Domain Models

Your development environment is set up and ready. Now it is time to scaffold the actual API project and define the data structures that represent your industrial domain.

Every API starts with a clear picture of the data it manages. For an industrial asset management system, that means modeling the equipment (assets) and the maintenance tasks (work orders) that keep them running. You will use the .NET CLI to scaffold a controller-based Web API project, then create Entity Framework Core entity classes that map directly to database tables.

In this step, get ready to:
  • Scaffold the ASP.NET Core Web API project and clean up the template defaults.
  • Create entity models for assets and maintenance work orders.
  • Add EF Core packages and create the database context class.
Scaffold the ASP.NET Core Web API project

The .NET CLI includes a template that generates a ready-to-run Web API project with controllers, routing, and OpenAPI support already wired up.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder by running this command:
dotnet new webapi --use-controllers -o AssetMaintenanceApi

What does this command do?

The dotnet new webapi command scaffolds a new ASP.NET Core Web API project. The --use-controllers flag tells it to generate a controller-based project (as opposed to Minimal APIs). The -o AssetMaintenanceApi flag creates the project inside a new folder called AssetMaintenanceApi.

  • Move into the project folder by running:
  • Open it in VS Code by running:
cd AssetMaintenanceApi
code .

Seeing a trust dialog?

VS Code may ask if you trust the authors of the files in this folder. Select Yes, I trust the authors since the project was generated by the .NET SDK.

The template generates sample files that you do not need. You will remove them now to keep the project clean.

  • In VS Code, open the Explorer sidebar (Ctrl+Shift+E).
  • Right-click on WeatherForecast.cs in the file list and select Delete.
  • Expand the Controllers folder.
  • Right-click on WeatherForecastController.cs and select Delete.

Your project now has a clean Controllers folder ready for your own controllers.

Don't see the files?

Make sure you opened the AssetMaintenanceApi folder itself in VS Code, not its parent folder. The WeatherForecast.cs file sits at the project root, and WeatherForecastController.cs is inside the Controllers folder.

If VS Code opened a different folder, click File then Open Folder and navigate to Desktop > AssetMaintenanceApi.

Help me find my project files in VS Code.

Create the entity models

Entity models are C# classes that represent your database tables. Each property becomes a column. You will create two models: one for industrial assets (equipment) and one for maintenance work orders.

  • In the Explorer sidebar, right-click the project root and select New Folder.
  • Name the folder Models.
  • Right-click the Models folder and select New File.
  • Name the file Asset.cs.
  • Paste the following code into Asset.cs:
namespace AssetMaintenanceApi.Models;

public class Asset
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string AssetType { get; set; } = string.Empty;
    public string Location { get; set; } = string.Empty;
    public DateTime InstallDate { get; set; }
    public string Status { get; set; } = "Active";

    // Navigation property - one asset can have many work orders
    public ICollection<MaintenanceWorkOrder> WorkOrders { get; set; } = new List<MaintenanceWorkOrder>();
}

What does this code do?

This class defines an industrial asset (a pump, motor, or conveyor). Each property maps to a database column.

  • Id is the primary key. EF Core auto-generates this value.
  • Name, AssetType, and Location describe the equipment.
  • Status defaults to "Active" for newly created assets.
  • WorkOrders is a navigation property. It tells EF Core that one asset can have many associated work orders.
  • Save Asset.cs.

✔️ Awesome, I've got everything!

Great. Make sure you have saved your file before moving on.

ⓧ I'd like to double check the full code

namespace AssetMaintenanceApi.Models;

public class Asset
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string AssetType { get; set; } = string.Empty;
    public string Location { get; set; } = string.Empty;
    public DateTime InstallDate { get; set; }
    public string Status { get; set; } = "Active";

    public ICollection<MaintenanceWorkOrder> WorkOrders { get; set; } = new List<MaintenanceWorkOrder>();
}

Now create the work order model that links maintenance tasks to specific assets.

  • Right-click the Models folder and select New File.
  • Name the file MaintenanceWorkOrder.cs.
  • Paste the following code into MaintenanceWorkOrder.cs:
namespace AssetMaintenanceApi.Models;

public class MaintenanceWorkOrder
{
    public int Id { get; set; }
    public int AssetId { get; set; }
    public string Description { get; set; } = string.Empty;
    public string Priority { get; set; } = "Medium";
    public string Status { get; set; } = "Scheduled";
    public DateTime ScheduledDate { get; set; }
    public DateTime? CompletedDate { get; set; }

    // Navigation property - each work order belongs to one asset
    public Asset Asset { get; set; } = null!;
}

What does this code do?

This class represents a maintenance task scheduled against an asset.

  • AssetId is the foreign key that links this work order to its parent asset.
  • CompletedDate is nullable (DateTime?) because a scheduled work order has not been completed yet.
  • Asset is the navigation property pointing back to the parent. The null! tells the compiler that EF Core will populate this value at runtime.
  • Save MaintenanceWorkOrder.cs.

✔️ Awesome, I've got everything!

Great. Make sure you have saved your file before moving on.

ⓧ I'd like to double check the full code

namespace AssetMaintenanceApi.Models;

public class MaintenanceWorkOrder
{
    public int Id { get; set; }
    public int AssetId { get; set; }
    public string Description { get; set; } = string.Empty;
    public string Priority { get; set; } = "Medium";
    public string Status { get; set; } = "Scheduled";
    public DateTime ScheduledDate { get; set; }
    public DateTime? CompletedDate { get; set; }

    public Asset Asset { get; set; } = null!;
}

Seeing red squiggly lines under MaintenanceWorkOrder?

If Asset.cs shows an error on MaintenanceWorkOrder, that is expected. The compiler cannot find the type until you save MaintenanceWorkOrder.cs. Once both files are saved, the errors disappear.

Help me fix red underline errors in my model files.

Add EF Core packages and create the database context

Entity Framework Core needs NuGet packages to talk to SQL Server. You also need a DbContext class that tells EF Core which entities to manage and how to configure them.

  • Open the integrated terminal in VS Code (Ctrl+`).
  • Install the EF Core SQL Server provider by running:
dotnet add package Microsoft.EntityFrameworkCore.SqlServer --version 9.0.0
  • Install the EF Core design-time tools package by running:
dotnet add package Microsoft.EntityFrameworkCore.Design --version 9.0.0
  • Install the Swagger UI package by running:
dotnet add package Swashbuckle.AspNetCore --version 7.2.0

Why three packages?

Microsoft.EntityFrameworkCore.SqlServer lets EF Core communicate with SQL Server databases. Microsoft.EntityFrameworkCore.Design enables the migration commands you will use in the next step to create your database schema.

Swashbuckle.AspNetCore adds an interactive Swagger UI page so anyone can explore and test your API endpoints in a browser.

Now create the database context class that ties your models to a real database.

  • In the Explorer sidebar, right-click the project root and select New Folder.
  • Name the folder Data.
  • Right-click the Data folder and select New File.
  • Name the file AppDbContext.cs.
  • Paste the following code into AppDbContext.cs:
using Microsoft.EntityFrameworkCore;
using AssetMaintenanceApi.Models;

namespace AssetMaintenanceApi.Data;

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

    // Each DbSet maps to a database table
    public DbSet<Asset> Assets => Set<Asset>();
    public DbSet<MaintenanceWorkOrder> WorkOrders => Set<MaintenanceWorkOrder>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Configure Asset entity constraints
        modelBuilder.Entity<Asset>(entity =>
        {
            entity.Property(e => e.Name).HasMaxLength(200).IsRequired();
            entity.Property(e => e.AssetType).HasMaxLength(100).IsRequired();
            entity.Property(e => e.Location).HasMaxLength(200).IsRequired();
            entity.Property(e => e.Status).HasMaxLength(50).IsRequired();
        });

        // Configure WorkOrder entity constraints and relationship
        modelBuilder.Entity<MaintenanceWorkOrder>(entity =>
        {
            entity.Property(e => e.Description).HasMaxLength(500).IsRequired();
            entity.Property(e => e.Priority).HasMaxLength(50).IsRequired();
            entity.Property(e => e.Status).HasMaxLength(50).IsRequired();

            entity.HasOne(e => e.Asset)
                .WithMany(a => a.WorkOrders)
                .HasForeignKey(e => e.AssetId)
                .OnDelete(DeleteBehavior.Cascade);
        });
    }
}

What does this code do?

The AppDbContext class is the bridge between your C# models and the SQL Server database.

  • DbSet<Asset> Assets tells EF Core to create an Assets table.
  • DbSet<MaintenanceWorkOrder> WorkOrders tells EF Core to create a WorkOrders table.
  • OnModelCreating configures column constraints (max lengths, required fields) and the foreign key relationship. Deleting an asset cascades to its work orders.
  • Save AppDbContext.cs.
  • Verify your packages were added correctly by opening AssetMaintenanceApi.csproj in the Explorer sidebar. You should see four PackageReference entries: Microsoft.AspNetCore.OpenApi, Microsoft.EntityFrameworkCore.SqlServer, Microsoft.EntityFrameworkCore.Design, and Swashbuckle.AspNetCore.

✔️ Awesome, I've got everything!

Great. Double check you have saved all files before moving on.

ⓧ I'd like to double check the full code

Here is what AppDbContext.cs should look like:

using Microsoft.EntityFrameworkCore;
using AssetMaintenanceApi.Models;

namespace AssetMaintenanceApi.Data;

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

    public DbSet<Asset> Assets => Set<Asset>();
    public DbSet<MaintenanceWorkOrder> WorkOrders => Set<MaintenanceWorkOrder>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Asset>(entity =>
        {
            entity.Property(e => e.Name).HasMaxLength(200).IsRequired();
            entity.Property(e => e.AssetType).HasMaxLength(100).IsRequired();
            entity.Property(e => e.Location).HasMaxLength(200).IsRequired();
            entity.Property(e => e.Status).HasMaxLength(50).IsRequired();
        });

        modelBuilder.Entity<MaintenanceWorkOrder>(entity =>
        {
            entity.Property(e => e.Description).HasMaxLength(500).IsRequired();
            entity.Property(e => e.Priority).HasMaxLength(50).IsRequired();
            entity.Property(e => e.Status).HasMaxLength(50).IsRequired();

            entity.HasOne(e => e.Asset)
                .WithMany(a => a.WorkOrders)
                .HasForeignKey(e => e.AssetId)
                .OnDelete(DeleteBehavior.Cascade);
        });
    }
}

And here is what AssetMaintenanceApi.csproj should look like:

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net9.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.0" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.0" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.0" />
    <PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" />
  </ItemGroup>

</Project>

Package restore errors?

If you see errors about packages not being found, make sure you have internet access and run dotnet restore in the terminal. This forces NuGet to download any missing packages.

If the version is not available, check that your .NET SDK is version 9.0 or higher by running dotnet --version.

Help me fix NuGet package restore errors.

Your domain models and database context are in place. Next up, you will wire the database connection and run your first migration to create the actual SQL Server tables.

Configure EF Core and Create the Database

You have your entity models and a DbContext class ready, but they're not connected to a real database yet.

In this step, you'll wire up the database connection, tell your app how to reach SQL Server LocalDB, and run your first Entity Framework Core migration to create the actual database tables.

In this step, get ready to:
  • Configure the connection string and register the DbContext in your app.
  • Install EF Core tools and run the initial migration.
  • Verify the database exists by launching the app and loading Swagger UI.
Configure the connection string in appsettings.json

Your app needs to know where to find the database. The connection string tells EF Core exactly which SQL Server instance and database name to use.

  • In VS Code, open appsettings.json from the root of your AssetMaintenanceApi project.
  • Replace the entire file contents with the following:
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "ConnectionStrings": {
    "DefaultConnection": "Server=(localdb)\\MSSQLLocalDB;Database=AssetMaintenanceDb;Trusted_Connection=True;TrustServerCertificate=True;"
  }
}

What does this connection string do?

Each part of the connection string tells EF Core where and how to connect:

  • Server=(localdb)\MSSQLLocalDB points to the default LocalDB instance on your machine.
  • Database=AssetMaintenanceDb is the name of the database EF Core will create for you.
  • Trusted_Connection=True uses your Windows login instead of a username and password.
  • TrustServerCertificate=True is needed for local development with newer SQL client versions.
  • Save appsettings.json.
Register the DbContext in Program.cs

ASP.NET Core uses dependency injection to provide services (like database access) to your controllers. You need to register your AppDbContext so the framework knows how to create it when a controller asks for it.

  • Open Program.cs in the root of your project.
  • Replace the entire file contents with the following code:
using Microsoft.EntityFrameworkCore;
using AssetMaintenanceApi.Data;

var builder = WebApplication.CreateBuilder(args);

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

// Register the database context with the SQL Server connection string
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
    app.UseSwaggerUI(options =>
    {
        options.SwaggerEndpoint("/openapi/v1.json", "Asset Maintenance API v1");
    });
}

app.UseHttpsRedirection();
app.MapControllers();
app.Run();

What does this code do?

  • AddDbContext<AppDbContext> registers your database context with the dependency injection container. Any controller that needs database access can request it through its constructor.
  • UseSqlServer(...) tells EF Core to use the SQL Server provider and reads the connection string from appsettings.json.
  • UseSwaggerUI adds the interactive Swagger documentation page, pointing it at the OpenAPI JSON spec that MapOpenApi() generates.
  • Save Program.cs.

Seeing red squiggles under AppDbContext or UseSqlServer?

Make sure you have the using Microsoft.EntityFrameworkCore; and using AssetMaintenanceApi.Data; lines at the top of the file. If OmniSharp hasn't loaded yet, try restarting VS Code.

help me fix red squiggles in Program.cs for EF Core

Install EF Core tools and create the database

EF Core migrations translate your C# model classes into SQL database tables. The dotnet-ef tool handles creating and applying these migrations from the command line.

  • Install the EF Core command-line tools globally by running this command:
dotnet tool install --global dotnet-ef
  • You should see a message confirming the tool was installed successfully.

Seeing 'already installed' or a version conflict?

If you already have dotnet-ef installed from a previous project, you can update it instead by running dotnet tool update --global dotnet-ef. Either way, you just need the tool available.

help me resolve a dotnet-ef tool installation issue

  • Create the initial migration by running:
dotnet ef migrations add InitialCreate

What does this command do?

This generates a Migrations/ folder with C# files that describe how to build your database schema. The migration reads your AppDbContext and model classes, then generates the SQL instructions to create the Assets and WorkOrders tables with all the constraints you defined.

  • You should see a new Migrations/ folder appear in your project explorer with timestamped migration files.
  • Apply the migration to create the actual database by running:
dotnet ef database update
  • You should see output ending with Done. confirming the database was created on your LocalDB instance.

Getting a connection error?

Make sure your LocalDB instance is running. Open a new terminal and run sqllocaldb start MSSQLLocalDB to start it manually. Then try dotnet ef database update again.

help me fix an EF Core database connection error with LocalDB

  • Verify the app runs successfully by starting it:
dotnet run
  • Look at the terminal output for a line showing the HTTPS URL (it will look something like https://localhost:7XXX).
  • Open that HTTPS URL in your browser and add /swagger to the end (for example, https://localhost:7123/swagger).
  • You should see the Swagger UI page with the title "Asset Maintenance API v1" and no endpoints listed yet.

Swagger page not loading?

Make sure you're using the HTTPS URL (not HTTP). If you see a certificate warning, click through it. The Swagger UI is only enabled in the Development environment, which is the default when running with dotnet run.

help me get Swagger UI loading in my ASP.NET Core app

  • Press Ctrl+C in the terminal to stop the app when you're done checking.

✔️ Awesome, I've got everything!

Great. Make sure all your files are saved before moving on.

ⓧ I'd like to double check the full code

Here is what each modified file should look like at this point:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "ConnectionStrings": {
    "DefaultConnection": "Server=(localdb)\\MSSQLLocalDB;Database=AssetMaintenanceDb;Trusted_Connection=True;TrustServerCertificate=True;"
  }
}
using Microsoft.EntityFrameworkCore;
using AssetMaintenanceApi.Data;

var builder = WebApplication.CreateBuilder(args);

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

// Register the database context with the SQL Server connection string
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
    app.UseSwaggerUI(options =>
    {
        options.SwaggerEndpoint("/openapi/v1.json", "Asset Maintenance API v1");
    });
}

app.UseHttpsRedirection();
app.MapControllers();
app.Run();

Your database is live and your app is serving the Swagger documentation page. Next up, you'll build the Asset CRUD endpoints that actually populate this API with useful functionality.

Build Asset CRUD Endpoints

Your database is ready and waiting. Now you need the actual API layer that lets clients create, read, update, and delete assets.

In this step, you will build a clean architecture with three layers: DTOs (Data Transfer Objects) that define what the API accepts and returns, a service layer that handles the business logic, and a controller that maps HTTP requests to service calls.

In this step, get ready to:
  • Create DTOs to separate your API contract from your database entities.
  • Build a service layer with full CRUD logic for assets.
  • Wire up an Assets controller with five endpoints returning proper HTTP status codes.
Create the Asset DTOs

Your Asset entity contains database-specific details like navigation properties and auto-generated IDs. Exposing that directly through the API leaks your database structure to clients.

DTOs solve this by defining separate classes for what the API accepts (input) and what it returns (output).

  • In the Explorer sidebar in VS Code, right-click the AssetMaintenanceApi project folder and select New Folder.
  • Name the folder DTOs.
  • Right-click the DTOs folder and select New File.
  • Name the file AssetDtos.cs.
  • Paste the following code into DTOs/AssetDtos.cs:
namespace AssetMaintenanceApi.DTOs;

public class CreateAssetDto
{
    public string Name { get; set; } = string.Empty;
    public string AssetType { get; set; } = string.Empty;
    public string Location { get; set; } = string.Empty;
    public DateTime InstallDate { get; set; }
}

public class UpdateAssetDto
{
    public string Name { get; set; } = string.Empty;
    public string AssetType { get; set; } = string.Empty;
    public string Location { get; set; } = string.Empty;
    public string Status { get; set; } = string.Empty;
}

public class AssetResponseDto
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string AssetType { get; set; } = string.Empty;
    public string Location { get; set; } = string.Empty;
    public DateTime InstallDate { get; set; }
    public string Status { get; set; } = string.Empty;
    public int WorkOrderCount { get; set; }
}

What do these DTOs do?

  • CreateAssetDto defines what a client sends when creating a new asset. It omits Id (auto-generated) and Status (defaults to "Active").
  • UpdateAssetDto includes Status because updates can change an asset's status (e.g. from "Active" to "Decommissioned").
  • AssetResponseDto defines what the API returns. It includes a WorkOrderCount field so clients can see how many work orders are linked to each asset.
  • Save DTOs/AssetDtos.cs.

Why separate DTOs from entities?

If you expose your Asset entity directly, any database schema change (adding a column, renaming a field) breaks every API client. DTOs create a stable contract that can evolve independently of the database.

This pattern is standard in production .NET APIs and is what hiring managers look for in portfolio projects.

✔️ Awesome, I've got everything!

Great. Make sure you have saved DTOs/AssetDtos.cs before moving on.

ⓧ I'd like to double check the full code

namespace AssetMaintenanceApi.DTOs;

public class CreateAssetDto
{
    public string Name { get; set; } = string.Empty;
    public string AssetType { get; set; } = string.Empty;
    public string Location { get; set; } = string.Empty;
    public DateTime InstallDate { get; set; }
}

public class UpdateAssetDto
{
    public string Name { get; set; } = string.Empty;
    public string AssetType { get; set; } = string.Empty;
    public string Location { get; set; } = string.Empty;
    public string Status { get; set; } = string.Empty;
}

public class AssetResponseDto
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string AssetType { get; set; } = string.Empty;
    public string Location { get; set; } = string.Empty;
    public DateTime InstallDate { get; set; }
    public string Status { get; set; } = string.Empty;
    public int WorkOrderCount { get; set; }
}
Create the Asset service interface and implementation

The service layer sits between the controller and the database. It handles the logic of mapping DTOs to entities and querying the AppDbContext.

You will create an interface first, then the implementation. This separation lets ASP.NET Core's dependency injection system wire things up automatically.

  • In the Explorer sidebar, right-click the AssetMaintenanceApi project folder and select New Folder.
  • Name the folder Services.
  • Right-click the Services folder and select New File.
  • Name the file IAssetService.cs.
  • Paste the following code into Services/IAssetService.cs:
using AssetMaintenanceApi.DTOs;

namespace AssetMaintenanceApi.Services;

public interface IAssetService
{
    Task<List<AssetResponseDto>> GetAllAsync();
    Task<AssetResponseDto?> GetByIdAsync(int id);
    Task<AssetResponseDto> CreateAsync(CreateAssetDto dto);
    Task<AssetResponseDto?> UpdateAsync(int id, UpdateAssetDto dto);
    Task<bool> DeleteAsync(int id);
}

What does this interface do?

The interface defines the contract for asset operations without any implementation details. Each method is asynchronous (returns a Task) because database operations should not block the thread.

Methods returning AssetResponseDto? (nullable) indicate that the asset might not exist. The controller uses this to decide whether to return a 200 or 404.

  • Save Services/IAssetService.cs.
  • Right-click the Services folder again and select New File.
  • Name the file AssetService.cs.
  • Paste the following code into Services/AssetService.cs:
using Microsoft.EntityFrameworkCore;
using AssetMaintenanceApi.Data;
using AssetMaintenanceApi.DTOs;
using AssetMaintenanceApi.Models;

namespace AssetMaintenanceApi.Services;

public class AssetService : IAssetService
{
    private readonly AppDbContext _context;

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

    public async Task<List<AssetResponseDto>> GetAllAsync()
    {
        return await _context.Assets
            .Select(a => new AssetResponseDto
            {
                Id = a.Id,
                Name = a.Name,
                AssetType = a.AssetType,
                Location = a.Location,
                InstallDate = a.InstallDate,
                Status = a.Status,
                WorkOrderCount = a.WorkOrders.Count
            })
            .ToListAsync();
    }

    public async Task<AssetResponseDto?> GetByIdAsync(int id)
    {
        return await _context.Assets
            .Where(a => a.Id == id)
            .Select(a => new AssetResponseDto
            {
                Id = a.Id,
                Name = a.Name,
                AssetType = a.AssetType,
                Location = a.Location,
                InstallDate = a.InstallDate,
                Status = a.Status,
                WorkOrderCount = a.WorkOrders.Count
            })
            .FirstOrDefaultAsync();
    }

    public async Task<AssetResponseDto> CreateAsync(CreateAssetDto dto)
    {
        var asset = new Asset
        {
            Name = dto.Name,
            AssetType = dto.AssetType,
            Location = dto.Location,
            InstallDate = dto.InstallDate,
            Status = "Active"
        };

        _context.Assets.Add(asset);
        await _context.SaveChangesAsync();

        return new AssetResponseDto
        {
            Id = asset.Id,
            Name = asset.Name,
            AssetType = asset.AssetType,
            Location = asset.Location,
            InstallDate = asset.InstallDate,
            Status = asset.Status,
            WorkOrderCount = 0
        };
    }

    public async Task<AssetResponseDto?> UpdateAsync(int id, UpdateAssetDto dto)
    {
        var asset = await _context.Assets.FindAsync(id);
        if (asset == null) return null;

        asset.Name = dto.Name;
        asset.AssetType = dto.AssetType;
        asset.Location = dto.Location;
        asset.Status = dto.Status;

        await _context.SaveChangesAsync();

        return new AssetResponseDto
        {
            Id = asset.Id,
            Name = asset.Name,
            AssetType = asset.AssetType,
            Location = asset.Location,
            InstallDate = asset.InstallDate,
            Status = asset.Status,
            WorkOrderCount = await _context.WorkOrders.CountAsync(w => w.AssetId == id)
        };
    }

    public async Task<bool> DeleteAsync(int id)
    {
        var asset = await _context.Assets.FindAsync(id);
        if (asset == null) return false;

        _context.Assets.Remove(asset);
        await _context.SaveChangesAsync();
        return true;
    }
}

What does this service do?

  • GetAllAsync uses a LINQ Select projection to map each Asset entity into an AssetResponseDto, including the count of related work orders.
  • GetByIdAsync returns null if the asset does not exist, which the controller translates to a 404 response.
  • CreateAsync maps the CreateAssetDto into a new Asset entity, saves it to the database, and returns the new asset with its auto-generated Id.
  • UpdateAsync finds the existing asset, updates its properties, saves, and returns the updated DTO.
  • DeleteAsync returns false if the asset does not exist, or true after successful deletion.
  • Save Services/AssetService.cs.

Seeing red squiggly lines in VS Code?

Red underlines in AssetService.cs may appear briefly while the C# Dev Kit extension indexes the new files. Give it a few seconds to resolve. If they persist, check that your DTOs/AssetDtos.cs and Models/Asset.cs files are saved.

help me fix red squiggly lines in my AssetService.cs file

✔️ Awesome, I've got everything!

Great. Make sure both Services/IAssetService.cs and Services/AssetService.cs are saved.

ⓧ I'd like to double check the full code

Here is Services/IAssetService.cs:

using AssetMaintenanceApi.DTOs;

namespace AssetMaintenanceApi.Services;

public interface IAssetService
{
    Task<List<AssetResponseDto>> GetAllAsync();
    Task<AssetResponseDto?> GetByIdAsync(int id);
    Task<AssetResponseDto> CreateAsync(CreateAssetDto dto);
    Task<AssetResponseDto?> UpdateAsync(int id, UpdateAssetDto dto);
    Task<bool> DeleteAsync(int id);
}

Here is Services/AssetService.cs:

using Microsoft.EntityFrameworkCore;
using AssetMaintenanceApi.Data;
using AssetMaintenanceApi.DTOs;
using AssetMaintenanceApi.Models;

namespace AssetMaintenanceApi.Services;

public class AssetService : IAssetService
{
    private readonly AppDbContext _context;

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

    public async Task<List<AssetResponseDto>> GetAllAsync()
    {
        return await _context.Assets
            .Select(a => new AssetResponseDto
            {
                Id = a.Id,
                Name = a.Name,
                AssetType = a.AssetType,
                Location = a.Location,
                InstallDate = a.InstallDate,
                Status = a.Status,
                WorkOrderCount = a.WorkOrders.Count
            })
            .ToListAsync();
    }

    public async Task<AssetResponseDto?> GetByIdAsync(int id)
    {
        return await _context.Assets
            .Where(a => a.Id == id)
            .Select(a => new AssetResponseDto
            {
                Id = a.Id,
                Name = a.Name,
                AssetType = a.AssetType,
                Location = a.Location,
                InstallDate = a.InstallDate,
                Status = a.Status,
                WorkOrderCount = a.WorkOrders.Count
            })
            .FirstOrDefaultAsync();
    }

    public async Task<AssetResponseDto> CreateAsync(CreateAssetDto dto)
    {
        var asset = new Asset
        {
            Name = dto.Name,
            AssetType = dto.AssetType,
            Location = dto.Location,
            InstallDate = dto.InstallDate,
            Status = "Active"
        };

        _context.Assets.Add(asset);
        await _context.SaveChangesAsync();

        return new AssetResponseDto
        {
            Id = asset.Id,
            Name = asset.Name,
            AssetType = asset.AssetType,
            Location = asset.Location,
            InstallDate = asset.InstallDate,
            Status = asset.Status,
            WorkOrderCount = 0
        };
    }

    public async Task<AssetResponseDto?> UpdateAsync(int id, UpdateAssetDto dto)
    {
        var asset = await _context.Assets.FindAsync(id);
        if (asset == null) return null;

        asset.Name = dto.Name;
        asset.AssetType = dto.AssetType;
        asset.Location = dto.Location;
        asset.Status = dto.Status;

        await _context.SaveChangesAsync();

        return new AssetResponseDto
        {
            Id = asset.Id,
            Name = asset.Name,
            AssetType = asset.AssetType,
            Location = asset.Location,
            InstallDate = asset.InstallDate,
            Status = asset.Status,
            WorkOrderCount = await _context.WorkOrders.CountAsync(w => w.AssetId == id)
        };
    }

    public async Task<bool> DeleteAsync(int id)
    {
        var asset = await _context.Assets.FindAsync(id);
        if (asset == null) return false;

        _context.Assets.Remove(asset);
        await _context.SaveChangesAsync();
        return true;
    }
}

Now you need to register the service in the dependency injection container so ASP.NET Core knows how to provide an IAssetService when a controller requests one.

  • Open Program.cs in VS Code.
  • Find this line:
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
  • Add the following line directly below it:
builder.Services.AddScoped<IAssetService, AssetService>();

What does AddScoped do?

AddScoped registers the service with a scoped lifetime. This means ASP.NET Core creates one AssetService instance per HTTP request. It matches the lifetime of the AppDbContext (also scoped by default), which is the correct pattern when your service depends on a DbContext.

  • Save Program.cs.

✔️ Awesome, I've got everything!

Great. Your Program.cs now has the DI registration for your asset service.

ⓧ I'd like to double check the full code

using Microsoft.EntityFrameworkCore;
using AssetMaintenanceApi.Data;
using AssetMaintenanceApi.Services;

var builder = WebApplication.CreateBuilder(args);

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

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

builder.Services.AddScoped<IAssetService, AssetService>();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
    app.UseSwaggerUI(options =>
    {
        options.SwaggerEndpoint("/openapi/v1.json", "Asset Maintenance API v1");
    });
}

app.UseHttpsRedirection();
app.MapControllers();
app.Run();
Create the Assets controller

The controller is the entry point for HTTP requests. It receives requests, calls the service, and returns the appropriate HTTP status code with the response data.

  • In the Explorer sidebar, right-click the AssetMaintenanceApi project folder and select New Folder.
  • Name the folder Controllers.
  • Right-click the Controllers folder and select New File.
  • Name the file AssetsController.cs.
  • Paste the following code into Controllers/AssetsController.cs:
using Microsoft.AspNetCore.Mvc;
using AssetMaintenanceApi.DTOs;
using AssetMaintenanceApi.Services;

namespace AssetMaintenanceApi.Controllers;

[ApiController]
[Route("api/[controller]")]
public class AssetsController : ControllerBase
{
    private readonly IAssetService _assetService;

    public AssetsController(IAssetService assetService)
    {
        _assetService = assetService;
    }

    [HttpGet]
    public async Task<ActionResult<List<AssetResponseDto>>> GetAll()
    {
        var assets = await _assetService.GetAllAsync();
        return Ok(assets);
    }

    [HttpGet("{id}")]
    public async Task<ActionResult<AssetResponseDto>> GetById(int id)
    {
        var asset = await _assetService.GetByIdAsync(id);
        if (asset == null) return NotFound();
        return Ok(asset);
    }

    [HttpPost]
    public async Task<ActionResult<AssetResponseDto>> Create(CreateAssetDto dto)
    {
        var asset = await _assetService.CreateAsync(dto);
        return CreatedAtAction(nameof(GetById), new { id = asset.Id }, asset);
    }

    [HttpPut("{id}")]
    public async Task<ActionResult<AssetResponseDto>> Update(int id, UpdateAssetDto dto)
    {
        var asset = await _assetService.UpdateAsync(id, dto);
        if (asset == null) return NotFound();
        return Ok(asset);
    }

    [HttpDelete("{id}")]
    public async Task<IActionResult> Delete(int id)
    {
        var deleted = await _assetService.DeleteAsync(id);
        if (!deleted) return NotFound();
        return NoContent();
    }
}

What does this controller do?

  • [ApiController] enables automatic model validation and binds request bodies from JSON.
  • [Route("api/[controller]")] sets the base URL to /api/assets (the class name minus "Controller", lowercased).
  • GET endpoints return Ok() (200) or NotFound() (404).
  • POST returns CreatedAtAction() (201) with a Location header pointing to the new resource.
  • DELETE returns NoContent() (204) on success or NotFound() (404) if the asset does not exist.
  • Save Controllers/AssetsController.cs.
  • Return to the terminal and run the app:
dotnet run
  • Open the HTTPS URL shown in the terminal output (it will look like https://localhost:XXXX) and navigate to the Swagger UI page by appending /swagger to the URL.
  • You should see five new endpoints listed under the Assets section: GET, GET by ID, POST, PUT, and DELETE.

Test a POST request in Swagger

Click the POST /api/Assets row to expand it, then click Try it out. Replace the request body with a real asset (e.g. Name: "Pump A-101", AssetType: "Centrifugal Pump", Location: "Building 3") and click Execute. You should see a 201 response with the new asset's auto-generated ID.

Swagger UI not showing the Assets endpoints?

Make sure you saved all three new files (AssetDtos.cs, AssetService.cs, AssetsController.cs) and Program.cs. Stop the running app with Ctrl+C and run dotnet build to check for compile errors.

help me troubleshoot why my Assets endpoints are not appearing in Swagger UI

✔️ Awesome, I've got everything!

Great. Make sure Controllers/AssetsController.cs is saved.

ⓧ I'd like to double check the full code

using Microsoft.AspNetCore.Mvc;
using AssetMaintenanceApi.DTOs;
using AssetMaintenanceApi.Services;

namespace AssetMaintenanceApi.Controllers;

[ApiController]
[Route("api/[controller]")]
public class AssetsController : ControllerBase
{
    private readonly IAssetService _assetService;

    public AssetsController(IAssetService assetService)
    {
        _assetService = assetService;
    }

    [HttpGet]
    public async Task<ActionResult<List<AssetResponseDto>>> GetAll()
    {
        var assets = await _assetService.GetAllAsync();
        return Ok(assets);
    }

    [HttpGet("{id}")]
    public async Task<ActionResult<AssetResponseDto>> GetById(int id)
    {
        var asset = await _assetService.GetByIdAsync(id);
        if (asset == null) return NotFound();
        return Ok(asset);
    }

    [HttpPost]
    public async Task<ActionResult<AssetResponseDto>> Create(CreateAssetDto dto)
    {
        var asset = await _assetService.CreateAsync(dto);
        return CreatedAtAction(nameof(GetById), new { id = asset.Id }, asset);
    }

    [HttpPut("{id}")]
    public async Task<ActionResult<AssetResponseDto>> Update(int id, UpdateAssetDto dto)
    {
        var asset = await _assetService.UpdateAsync(id, dto);
        if (asset == null) return NotFound();
        return Ok(asset);
    }

    [HttpDelete("{id}")]
    public async Task<IActionResult> Delete(int id)
    {
        var deleted = await _assetService.DeleteAsync(id);
        if (!deleted) return NotFound();
        return NoContent();
    }
}

Your Assets API is fully operational. Next up, you will build the Work Orders controller that links maintenance tasks to specific assets using foreign key relationships.

Build Work Order Endpoints with Asset Relationships

Your Assets controller is live with full CRUD. Now it's time to build the second half of the domain: maintenance work orders that reference specific assets.

A work order without an asset is meaningless. Every work order must belong to an existing asset, and when you display a work order, you want to see the asset's name alongside it. This is where relational data and Entity Framework Core navigation properties come in.

In this step, get ready to:
  • Create DTOs that shape work order request and response data.
  • Build a service layer that validates asset relationships before creating work orders.
  • Create the WorkOrders controller and test the full relational flow in Swagger.
Create work order DTOs

Work order DTOs follow the same separation pattern you used for assets. The key difference is that the response DTO includes the asset's name so API consumers can display it without making a second request.

  • In the DTOs folder, create a new file called WorkOrderDtos.cs.
  • Add the following code to define the three work order DTOs:
namespace AssetMaintenanceApi.DTOs;

public class CreateWorkOrderDto
{
    public int AssetId { get; set; }
    public string Description { get; set; } = string.Empty;
    public string Priority { get; set; } = "Medium";
    public DateTime ScheduledDate { get; set; }
}

public class UpdateWorkOrderDto
{
    public string Description { get; set; } = string.Empty;
    public string Priority { get; set; } = string.Empty;
    public string Status { get; set; } = string.Empty;
    public DateTime ScheduledDate { get; set; }
    public DateTime? CompletedDate { get; set; }
}

public class WorkOrderResponseDto
{
    public int Id { get; set; }
    public int AssetId { get; set; }
    public string AssetName { get; set; } = string.Empty;
    public string Description { get; set; } = string.Empty;
    public string Priority { get; set; } = string.Empty;
    public string Status { get; set; } = string.Empty;
    public DateTime ScheduledDate { get; set; }
    public DateTime? CompletedDate { get; set; }
}

What does this code do?

  • CreateWorkOrderDto requires an AssetId so every work order is linked to a specific asset. It defaults Priority to "Medium" so callers can omit it.
  • UpdateWorkOrderDto includes a nullable CompletedDate so you can mark a work order as finished by setting this field.
  • WorkOrderResponseDto includes AssetName alongside the raw AssetId. This saves API consumers from making a separate call to look up the asset.
  • Save DTOs/WorkOrderDtos.cs.

Seeing red squiggles in the file?

Make sure the namespace matches exactly: AssetMaintenanceApi.DTOs. If IntelliSense is slow, save the file and wait a moment for the C# Dev Kit to re-analyze.

Still having trouble? Help me fix namespace errors in my WorkOrderDtos.cs file.

Create the work order service

The work order service follows the same interface-plus-implementation pattern you built for assets. The critical difference is that it validates the referenced asset exists before creating a work order, and it uses EF Core's Include method to load the related asset's name into every response.

  • In the Services folder, create a new file called IWorkOrderService.cs.
  • Add the following interface definition:
using AssetMaintenanceApi.DTOs;

namespace AssetMaintenanceApi.Services;

public interface IWorkOrderService
{
    Task<List<WorkOrderResponseDto>> GetAllAsync();
    Task<WorkOrderResponseDto?> GetByIdAsync(int id);
    Task<WorkOrderResponseDto?> CreateAsync(CreateWorkOrderDto dto);
    Task<WorkOrderResponseDto?> UpdateAsync(int id, UpdateWorkOrderDto dto);
    Task<bool> DeleteAsync(int id);
}

Why does CreateAsync return a nullable type?

Unlike IAssetService.CreateAsync which always succeeds, CreateAsync here returns WorkOrderResponseDto? (nullable). If the caller provides an AssetId that doesn't exist, the service returns null so the controller can respond with a 400 Bad Request.

  • Save Services/IWorkOrderService.cs.
  • Now create the implementation file. In the Services folder, create WorkOrderService.cs and add the following code:
using Microsoft.EntityFrameworkCore;
using AssetMaintenanceApi.Data;
using AssetMaintenanceApi.DTOs;
using AssetMaintenanceApi.Models;

namespace AssetMaintenanceApi.Services;

public class WorkOrderService : IWorkOrderService
{
    private readonly AppDbContext _context;

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

    public async Task<List<WorkOrderResponseDto>> GetAllAsync()
    {
        return await _context.WorkOrders
            .Include(w => w.Asset)
            .Select(w => new WorkOrderResponseDto
            {
                Id = w.Id,
                AssetId = w.AssetId,
                AssetName = w.Asset.Name,
                Description = w.Description,
                Priority = w.Priority,
                Status = w.Status,
                ScheduledDate = w.ScheduledDate,
                CompletedDate = w.CompletedDate
            })
            .ToListAsync();
    }

    public async Task<WorkOrderResponseDto?> GetByIdAsync(int id)
    {
        return await _context.WorkOrders
            .Include(w => w.Asset)
            .Where(w => w.Id == id)
            .Select(w => new WorkOrderResponseDto
            {
                Id = w.Id,
                AssetId = w.AssetId,
                AssetName = w.Asset.Name,
                Description = w.Description,
                Priority = w.Priority,
                Status = w.Status,
                ScheduledDate = w.ScheduledDate,
                CompletedDate = w.CompletedDate
            })
            .FirstOrDefaultAsync();
    }

    public async Task<WorkOrderResponseDto?> CreateAsync(CreateWorkOrderDto dto)
    {
        var asset = await _context.Assets.FindAsync(dto.AssetId);
        if (asset == null) return null;

        var workOrder = new MaintenanceWorkOrder
        {
            AssetId = dto.AssetId,
            Description = dto.Description,
            Priority = dto.Priority,
            Status = "Scheduled",
            ScheduledDate = dto.ScheduledDate
        };

        _context.WorkOrders.Add(workOrder);
        await _context.SaveChangesAsync();

        return new WorkOrderResponseDto
        {
            Id = workOrder.Id,
            AssetId = workOrder.AssetId,
            AssetName = asset.Name,
            Description = workOrder.Description,
            Priority = workOrder.Priority,
            Status = workOrder.Status,
            ScheduledDate = workOrder.ScheduledDate,
            CompletedDate = workOrder.CompletedDate
        };
    }

    public async Task<WorkOrderResponseDto?> UpdateAsync(int id, UpdateWorkOrderDto dto)
    {
        var workOrder = await _context.WorkOrders.Include(w => w.Asset).FirstOrDefaultAsync(w => w.Id == id);
        if (workOrder == null) return null;

        workOrder.Description = dto.Description;
        workOrder.Priority = dto.Priority;
        workOrder.Status = dto.Status;
        workOrder.ScheduledDate = dto.ScheduledDate;
        workOrder.CompletedDate = dto.CompletedDate;

        await _context.SaveChangesAsync();

        return new WorkOrderResponseDto
        {
            Id = workOrder.Id,
            AssetId = workOrder.AssetId,
            AssetName = workOrder.Asset.Name,
            Description = workOrder.Description,
            Priority = workOrder.Priority,
            Status = workOrder.Status,
            ScheduledDate = workOrder.ScheduledDate,
            CompletedDate = workOrder.CompletedDate
        };
    }

    public async Task<bool> DeleteAsync(int id)
    {
        var workOrder = await _context.WorkOrders.FindAsync(id);
        if (workOrder == null) return false;

        _context.WorkOrders.Remove(workOrder);
        await _context.SaveChangesAsync();
        return true;
    }
}

What's happening here?

This service introduces two patterns that weren't needed in AssetService:

  • Include(w => w.Asset) tells EF Core to load the related Asset entity alongside each work order. Without this, w.Asset.Name would be null because EF Core uses lazy loading by default.
  • In CreateAsync, the line if (asset == null) return null validates that the referenced asset actually exists. This prevents orphaned work orders pointing to non-existent assets.
  • In UpdateAsync, the query uses Include(w => w.Asset).FirstOrDefaultAsync() instead of FindAsync() because FindAsync doesn't support eager loading of navigation properties.
  • Save Services/WorkOrderService.cs.

Seeing errors about missing types?

Make sure you have the using Microsoft.EntityFrameworkCore; directive at the top. The Include method lives in that namespace.

Also confirm that DTOs/WorkOrderDtos.cs is saved. If those DTO classes don't compile, this file will show errors too.

Need more help? Help me fix compile errors in my WorkOrderService.

✔️ Awesome, I've got everything!

Great. Make sure both service files are saved before moving on.

ⓧ I'd like to double check the full code

Here is the complete IWorkOrderService.cs:

using AssetMaintenanceApi.DTOs;

namespace AssetMaintenanceApi.Services;

public interface IWorkOrderService
{
    Task<List<WorkOrderResponseDto>> GetAllAsync();
    Task<WorkOrderResponseDto?> GetByIdAsync(int id);
    Task<WorkOrderResponseDto?> CreateAsync(CreateWorkOrderDto dto);
    Task<WorkOrderResponseDto?> UpdateAsync(int id, UpdateWorkOrderDto dto);
    Task<bool> DeleteAsync(int id);
}

And here is the complete WorkOrderService.cs:

using Microsoft.EntityFrameworkCore;
using AssetMaintenanceApi.Data;
using AssetMaintenanceApi.DTOs;
using AssetMaintenanceApi.Models;

namespace AssetMaintenanceApi.Services;

public class WorkOrderService : IWorkOrderService
{
    private readonly AppDbContext _context;

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

    public async Task<List<WorkOrderResponseDto>> GetAllAsync()
    {
        return await _context.WorkOrders
            .Include(w => w.Asset)
            .Select(w => new WorkOrderResponseDto
            {
                Id = w.Id,
                AssetId = w.AssetId,
                AssetName = w.Asset.Name,
                Description = w.Description,
                Priority = w.Priority,
                Status = w.Status,
                ScheduledDate = w.ScheduledDate,
                CompletedDate = w.CompletedDate
            })
            .ToListAsync();
    }

    public async Task<WorkOrderResponseDto?> GetByIdAsync(int id)
    {
        return await _context.WorkOrders
            .Include(w => w.Asset)
            .Where(w => w.Id == id)
            .Select(w => new WorkOrderResponseDto
            {
                Id = w.Id,
                AssetId = w.AssetId,
                AssetName = w.Asset.Name,
                Description = w.Description,
                Priority = w.Priority,
                Status = w.Status,
                ScheduledDate = w.ScheduledDate,
                CompletedDate = w.CompletedDate
            })
            .FirstOrDefaultAsync();
    }

    public async Task<WorkOrderResponseDto?> CreateAsync(CreateWorkOrderDto dto)
    {
        var asset = await _context.Assets.FindAsync(dto.AssetId);
        if (asset == null) return null;

        var workOrder = new MaintenanceWorkOrder
        {
            AssetId = dto.AssetId,
            Description = dto.Description,
            Priority = dto.Priority,
            Status = "Scheduled",
            ScheduledDate = dto.ScheduledDate
        };

        _context.WorkOrders.Add(workOrder);
        await _context.SaveChangesAsync();

        return new WorkOrderResponseDto
        {
            Id = workOrder.Id,
            AssetId = workOrder.AssetId,
            AssetName = asset.Name,
            Description = workOrder.Description,
            Priority = workOrder.Priority,
            Status = workOrder.Status,
            ScheduledDate = workOrder.ScheduledDate,
            CompletedDate = workOrder.CompletedDate
        };
    }

    public async Task<WorkOrderResponseDto?> UpdateAsync(int id, UpdateWorkOrderDto dto)
    {
        var workOrder = await _context.WorkOrders.Include(w => w.Asset).FirstOrDefaultAsync(w => w.Id == id);
        if (workOrder == null) return null;

        workOrder.Description = dto.Description;
        workOrder.Priority = dto.Priority;
        workOrder.Status = dto.Status;
        workOrder.ScheduledDate = dto.ScheduledDate;
        workOrder.CompletedDate = dto.CompletedDate;

        await _context.SaveChangesAsync();

        return new WorkOrderResponseDto
        {
            Id = workOrder.Id,
            AssetId = workOrder.AssetId,
            AssetName = workOrder.Asset.Name,
            Description = workOrder.Description,
            Priority = workOrder.Priority,
            Status = workOrder.Status,
            ScheduledDate = workOrder.ScheduledDate,
            CompletedDate = workOrder.CompletedDate
        };
    }

    public async Task<bool> DeleteAsync(int id)
    {
        var workOrder = await _context.WorkOrders.FindAsync(id);
        if (workOrder == null) return false;

        _context.WorkOrders.Remove(workOrder);
        await _context.SaveChangesAsync();
        return true;
    }
}
Create the controller and test the full flow

The controller wires up HTTP endpoints to your service methods. It also translates service-level signals (like a null return) into proper HTTP status codes that API consumers expect.

  • In the Controllers folder, create a new file called WorkOrdersController.cs.
  • Add the following controller code:
using Microsoft.AspNetCore.Mvc;
using AssetMaintenanceApi.DTOs;
using AssetMaintenanceApi.Services;

namespace AssetMaintenanceApi.Controllers;

[ApiController]
[Route("api/[controller]")]
public class WorkOrdersController : ControllerBase
{
    private readonly IWorkOrderService _workOrderService;

    public WorkOrdersController(IWorkOrderService workOrderService)
    {
        _workOrderService = workOrderService;
    }

    [HttpGet]
    public async Task<ActionResult<List<WorkOrderResponseDto>>> GetAll()
    {
        var workOrders = await _workOrderService.GetAllAsync();
        return Ok(workOrders);
    }

    [HttpGet("{id}")]
    public async Task<ActionResult<WorkOrderResponseDto>> GetById(int id)
    {
        var workOrder = await _workOrderService.GetByIdAsync(id);
        if (workOrder == null) return NotFound();
        return Ok(workOrder);
    }

    [HttpPost]
    public async Task<ActionResult<WorkOrderResponseDto>> Create(CreateWorkOrderDto dto)
    {
        var workOrder = await _workOrderService.CreateAsync(dto);
        if (workOrder == null) return BadRequest("Asset not found.");
        return CreatedAtAction(nameof(GetById), new { id = workOrder.Id }, workOrder);
    }

    [HttpPut("{id}")]
    public async Task<ActionResult<WorkOrderResponseDto>> Update(int id, UpdateWorkOrderDto dto)
    {
        var workOrder = await _workOrderService.UpdateAsync(id, dto);
        if (workOrder == null) return NotFound();
        return Ok(workOrder);
    }

    [HttpDelete("{id}")]
    public async Task<IActionResult> Delete(int id)
    {
        var deleted = await _workOrderService.DeleteAsync(id);
        if (!deleted) return NotFound();
        return NoContent();
    }
}

What does this code do?

  • The Create method checks if the service returned null (meaning the asset doesn't exist) and returns a 400 Bad Request with the message "Asset not found.". This gives the API consumer a clear signal that they provided an invalid AssetId.
  • The route [Route("api/[controller]")] resolves to /api/workorders based on the class name minus the "Controller" suffix.
  • CreatedAtAction returns a 201 Created response with a Location header pointing to the new work order's URL.
  • Save Controllers/WorkOrdersController.cs.

Now you need to register the work order service in the dependency injection container so the controller can receive it.

  • Open Program.cs.
  • Find this line:
builder.Services.AddScoped<IAssetService, AssetService>();
  • Add this line directly below it:
builder.Services.AddScoped<IWorkOrderService, WorkOrderService>();
  • Save Program.cs.

Time to test the full relational flow. You'll create an asset first, then create a work order that references it.

  • Run the application by executing this command in your terminal:
dotnet run
  • Open the Swagger UI in your browser at the HTTPS URL shown in the terminal output (it will look like https://localhost:XXXX/swagger).
  • You should now see two sections: Assets and WorkOrders.

Test the relational flow:

  • Expand the POST /api/assets endpoint and click Try it out.
  • Replace the request body with sample data (e.g. a pump named "Main Cooling Pump") and click Execute.
  • Note the id value in the response (likely 1).
  • Expand the POST /api/workorders endpoint and click Try it out.
  • Set assetId to the asset ID you just noted, fill in a description and scheduled date, then click Execute.
  • In the response, confirm you see the assetName field populated with the asset's name.

What should I see?

The POST /api/workorders response should return a 201 Created status with a JSON body that includes both assetId (the number) and assetName (the human-readable name). This proves your navigation property and Include logic are working.

Getting a 400 Bad Request or 500 error?

If you see "Asset not found.", make sure you created an asset first and that the assetId in your work order request matches the id from the asset response.

If you see a 500 error, check your terminal for the full exception. The most common cause is a missing AddScoped<IWorkOrderService, WorkOrderService>() line in Program.cs.

Still stuck? Help me debug my WorkOrders endpoint in Swagger.

✔️ Awesome, I've got everything!

Great. Make sure all your new files are saved.

ⓧ I'd like to double check the full code

Here is the complete WorkOrdersController.cs:

using Microsoft.AspNetCore.Mvc;
using AssetMaintenanceApi.DTOs;
using AssetMaintenanceApi.Services;

namespace AssetMaintenanceApi.Controllers;

[ApiController]
[Route("api/[controller]")]
public class WorkOrdersController : ControllerBase
{
    private readonly IWorkOrderService _workOrderService;

    public WorkOrdersController(IWorkOrderService workOrderService)
    {
        _workOrderService = workOrderService;
    }

    [HttpGet]
    public async Task<ActionResult<List<WorkOrderResponseDto>>> GetAll()
    {
        var workOrders = await _workOrderService.GetAllAsync();
        return Ok(workOrders);
    }

    [HttpGet("{id}")]
    public async Task<ActionResult<WorkOrderResponseDto>> GetById(int id)
    {
        var workOrder = await _workOrderService.GetByIdAsync(id);
        if (workOrder == null) return NotFound();
        return Ok(workOrder);
    }

    [HttpPost]
    public async Task<ActionResult<WorkOrderResponseDto>> Create(CreateWorkOrderDto dto)
    {
        var workOrder = await _workOrderService.CreateAsync(dto);
        if (workOrder == null) return BadRequest("Asset not found.");
        return CreatedAtAction(nameof(GetById), new { id = workOrder.Id }, workOrder);
    }

    [HttpPut("{id}")]
    public async Task<ActionResult<WorkOrderResponseDto>> Update(int id, UpdateWorkOrderDto dto)
    {
        var workOrder = await _workOrderService.UpdateAsync(id, dto);
        if (workOrder == null) return NotFound();
        return Ok(workOrder);
    }

    [HttpDelete("{id}")]
    public async Task<IActionResult> Delete(int id)
    {
        var deleted = await _workOrderService.DeleteAsync(id);
        if (!deleted) return NotFound();
        return NoContent();
    }
}

And here is the updated Program.cs:

using Microsoft.EntityFrameworkCore;
using AssetMaintenanceApi.Data;
using AssetMaintenanceApi.Services;

var builder = WebApplication.CreateBuilder(args);

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

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

builder.Services.AddScoped<IAssetService, AssetService>();
builder.Services.AddScoped<IWorkOrderService, WorkOrderService>();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
    app.UseSwaggerUI(options =>
    {
        options.SwaggerEndpoint("/openapi/v1.json", "Asset Maintenance API v1");
    });
}

app.UseHttpsRedirection();
app.MapControllers();
app.Run();

Secret mission

Add Filtering, Search, and Pagination

Your API returns every asset in the database every time someone calls GET /api/assets. In production, that list could be thousands of items. Can you add query parameters for filtering by status, searching by name, and paginating results so the API stays fast no matter how large the dataset grows?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

This project runs entirely locally using SQL Server LocalDB, so there are no ongoing cloud costs.

Resources you used:

  • SQL Server LocalDB instance MSSQLLocalDB with the AssetMaintenanceDb database.
  • The AssetMaintenanceApi project folder on your Desktop.

Keep everything running

No action needed. Choose this if you're still actively building or want to extend the project further.

  • The LocalDB database persists between sessions automatically. You can continue developing anytime.
  • Your project folder stays on your Desktop, ready to open in VS Code.
  • Run dotnet run from the project folder to start the API again whenever you want.

Pause - I'll come back to this later

Stop the LocalDB instance to free up system resources while keeping all your data intact.

  • If your API is still running in the terminal, press Ctrl+C to stop it.
  • Stop the LocalDB instance by running this command in your terminal:
sqllocaldb stop MSSQLLocalDB
  • Your database and project files remain on disk. When you come back, LocalDB starts automatically when you run the app again.

Delete - I don't want to use this again

Remove the LocalDB instance and delete the project folder entirely.

  • If your API is still running in the terminal, press Ctrl+C to stop it.
  • Stop and delete the LocalDB instance by running these commands:
sqllocaldb stop MSSQLLocalDB
sqllocaldb delete MSSQLLocalDB
  • Delete the project folder from your Desktop:
cd ~/Desktop
rmdir /s /q AssetMaintenanceApi
  • Optionally, remove the global EF Core tool:
dotnet tool uninstall --global dotnet-ef
  • Your .NET SDK, VS Code, and SQL Server Express LocalDB software remain installed for future projects.

Nice Work!

Nice Work!

Nice work! You just built a production-style Industrial Asset & Maintenance Management REST API from scratch using ASP.NET Core 9.

You've learned how to:

  • Build an ASP.NET Core Web API with controller-based endpoints, proper routing, and HTTP status codes.
  • Create a relational SQL Server database using Entity Framework Core code-first migrations with foreign key relationships between Assets and Work Orders.
  • Implement full CRUD operations with DTOs and a service layer to separate your API contracts from your database entities.
  • Secret Mission: Added filtering, search, and pagination to the Assets endpoint to demonstrate production-ready query handling.

Ready to quiz yourself?