Theory doesn't ship features. You learned the why in Part 1—when serverless makes sense, the economics, how it compares to App Service and Container Apps. Now let's build something.
In this second part of the Azure Functions for .NET Developers series, we'll create a project from scratch, understand every line of generated code, and build three HTTP trigger patterns that cover most real-world API scenarios. By the end, you'll have a working HTTP API running locally on your machine. No Azure subscription required.
All code from this article is available in the azure-functions-samples repository.
Prerequisites
You need four tools installed before we start. Here's what to check and where to get them:
| Tool | Verify | Expected |
|---|---|---|
| .NET 10 SDK | dotnet --version |
10.x |
| Azure Functions Core Tools | func --version |
4.x |
| VS Code + Azure Functions extension | — | Latest |
| Azurite | npx azurite --version |
Any |
.NET 10 SDK is required to build and run our function app. It's an LTS release, supported through November 2028. Grab it from dot.net/download if you don't have it.
Azure Functions Core Tools v4 lets you create, run, and debug functions locally. Install via npm, Homebrew, or Chocolatey:
# npm (any OS)
npm install -g azure-functions-core-tools@4
# Homebrew (macOS)
brew tap azure/functions
brew install azure-functions-core-tools@4
# Chocolatey (Windows)
choco install azure-functions-core-tools-4
VS Code with the Azure Functions extension gives you project templates, debugging, and deployment in one place. Visual Studio and Rider work too—use whatever you're comfortable with. The CLI commands in this article work regardless of editor.
Azurite emulates Azure Storage locally. The Functions runtime needs a storage connection even for HTTP triggers (it uses storage internally for features like function key management and, in production, for coordinating across instances). Install it globally or run it on demand:
# Install globally
npm install -g azurite
# Or run on demand without installing
npx azurite
If the verification commands all return version numbers and you have VS Code with the Azure Functions extension installed, you're ready.
Creating Your First Project
With your tools in place, open a terminal and scaffold a new function app:
func init HttpTriggerDemo --worker-runtime dotnet-isolated --target-framework net10.0
cd HttpTriggerDemo
func new --template "HTTP trigger" --name Hello
The first command creates the project. --worker-runtime dotnet-isolated selects the isolated worker model (the modern default—we'll explain what "isolated" means in Part 5). --target-framework net10.0 targets .NET 10.
Once inside the project directory, func new adds an HTTP-triggered function called Hello from a built-in template.
Your project now looks like this:
HttpTriggerDemo/
├── Hello.cs # Your function code
├── Program.cs # Application entry point
├── HttpTriggerDemo.csproj # Project file
├── host.json # Runtime configuration
└── local.settings.json # Local environment variables
The Project File
Open HttpTriggerDemo.csproj:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<AzureFunctionsVersion>v4</AzureFunctionsVersion>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.Azure.Functions.Worker" Version="2.51.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="2.0.7" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" Version="2.1.0" />
</ItemGroup>
</Project>
A few things to note:
-
OutputType: Exe— the isolated worker model runs as a standalone executable, not a class library loaded into the Functions host. This is what gives you full control over the process. -
FrameworkReference: Microsoft.AspNetCore.App— brings in ASP.NET Core types likeHttpRequestandIActionResult. Without this, you'd use the lower-levelHttpRequestDataAPI. -
Extensions.Http.AspNetCore— the bridge that maps ASP.NET Core's HTTP abstractions to the Functions runtime. This is what makes the developer experience feel like writing a regular web API. -
ImplicitUsings: enable— the compiler automatically includes commonusingstatements (System,System.Collections.Generic,System.Linq,System.Threading.Tasks, and others). That's why our code files don't start with a block ofusingdirectives. -
Nullable: enable— turns on nullable reference types. The compiler warns you when code might dereferencenullwithout checking. You'll see this in action when we read query string values—req.Query["name"]returnsstring?, notstring.
The Entry Point
Program.cs is minimal:
using Microsoft.Azure.Functions.Worker.Builder;
var builder = FunctionsApplication.CreateBuilder(args);
builder.ConfigureFunctionsWebApplication();
builder.Build().Run();
ConfigureFunctionsWebApplication() is the key line. It enables ASP.NET Core integration—without it, you'd need to use HttpRequestData and HttpResponseData instead of the familiar HttpRequest and IActionResult. For HTTP-triggered functions, this is almost always what you want.
FunctionsApplication.CreateBuilder(args) is a convenience factory introduced in Worker SDK 2.0. It returns a pre-configured IHostApplicationBuilder with Functions defaults already wired up—logging, JSON serializer options, and middleware. The key using to remember is Microsoft.Azure.Functions.Worker.Builder, which brings the factory method into scope. You may also see an older pattern that starts with new HostBuilder() and calls .ConfigureFunctionsWorkerDefaults()—it still works, but requires more manual setup (for example, explicit ConfigureAppConfiguration() calls to load appsettings.json). func init scaffolds FunctionsApplication.CreateBuilder by default, so stick with it for new projects.
This is also where you'd register services for dependency injection, add middleware, or configure logging. We'll keep it simple for now.
Understanding the Generated Code
With the project structure in place, let's look at the function itself. The func new command created a file called Hello.cs with a complete working function. Before we run it, let's understand what each part does. Open Hello.cs:
public class Hello(ILogger<Hello> logger)
{
[Function("Hello")]
public IActionResult Run(
[HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequest req)
{
logger.LogInformation("C# HTTP trigger function processed a request.");
return new OkObjectResult("Welcome to Azure Functions!");
}
}
Let's break this apart.
public class Hello(ILogger<Hello> logger) uses a C# 12 primary constructor. The ILogger<Hello> parameter is injected by the dependency injection container automatically—no need to write a separate constructor or field assignment. The logger is available throughout the class.
[Function("Hello")] registers this method with the Functions runtime. The string "Hello" becomes the function's name and appears in the URL path: http://localhost:7071/api/Hello. If you rename the class but keep the attribute string the same, the URL stays the same.
[HttpTrigger(AuthorizationLevel.Function, "get", "post")] is where the behavior is defined:
-
AuthorizationLevel.Functionmeans callers need a function-specific API key to invoke this endpoint. Other options areAnonymous(no key required) andAdmin(requires the master host key). When running locally, authorization is bypassed regardless of the level you set. -
"get", "post"lists the allowed HTTP methods. Requests using other methods receive a 405 Method Not Allowed.
HttpRequest req is the full ASP.NET Core request object. Query strings, headers, body, route values—it's all there. If you've written ASP.NET Core controllers or minimal APIs, this is the same type.
IActionResult is the return type. OkObjectResult maps to a 200 response. The runtime serializes the object to JSON (or returns it as plain text for strings) and sends it back to the caller. All the familiar result types work: BadRequestResult, NotFoundResult, CreatedResult, and so on.
Run It
Before we modify anything, let's see this function in action. Start Azurite in a separate terminal (or in the background):
npx azurite --silent --location /tmp/azurite &
Then start the Functions runtime from your project directory:
func start
You should see output listing your function's URL:
Functions:
Hello: [GET,POST] http://localhost:7071/api/Hello
Test it in another terminal:
curl http://localhost:7071/api/Hello
# → Welcome to Azure Functions!
That's a working HTTP endpoint, running locally, no Azure subscription needed. Keep func start running—we'll build on this function next.
HTTP Trigger Deep Dive
With a running function under your belt, let's build three patterns that cover the majority of real-world HTTP function scenarios. After each change, stop func start with Ctrl+C and restart it to pick up the new code.
Pattern 1: GET with Query String
The simplest pattern—read a value from the URL and return a response:
public class HelloFunction(ILogger<HelloFunction> logger)
{
[Function("Hello")]
public IActionResult Run(
[HttpTrigger(AuthorizationLevel.Function, "get")] HttpRequest req)
{
logger.LogInformation("Hello function triggered");
string? name = req.Query["name"];
return new OkObjectResult($"Hello, {name ?? "world"}!");
}
}
req.Query["name"] reads the name parameter from the query string. If it's missing, the value is null, so we fall back to "world" with the null-coalescing operator. Note the string? — the query collection returns a nullable type, and with <Nullable>enable</Nullable> in the project file, the compiler enforces this.
Test it:
curl "http://localhost:7071/api/Hello?name=Azure"
# → Hello, Azure!
curl "http://localhost:7071/api/Hello"
# → Hello, world!
This pattern works well for simple lookups—search queries, filtering lists, or any GET request where the parameters are short and cacheable.
Pattern 2: POST with JSON Body
For creating or updating resources, you'll typically accept a JSON request body. C# records and ASP.NET Core model binding make this clean:
using FromBodyAttribute = Microsoft.Azure.Functions.Worker.Http.FromBodyAttribute;
public record CreateOrderRequest(string ProductId, int Quantity);
public class OrderFunction(ILogger<OrderFunction> logger)
{
[Function("CreateOrder")]
public IActionResult CreateOrder(
[HttpTrigger(AuthorizationLevel.Function, "post", Route = "orders")] HttpRequest req,
[FromBody] CreateOrderRequest order)
{
logger.LogInformation("Order for {ProductId} x{Quantity}", order.ProductId, order.Quantity);
return new CreatedResult($"/orders/{Guid.NewGuid()}", order);
}
}
Several things happening here:
record CreateOrderRequest defines an immutable data type. Records are ideal for request and response DTOs—they give you value equality, a clean ToString(), and immutability out of the box. No need to write a class with properties and a constructor.
[FromBody] CreateOrderRequest order tells the ASP.NET Core model binder to deserialize the JSON request body into a CreateOrderRequest instance. If the JSON doesn't match (missing required fields, wrong types), the runtime returns a 400 Bad Request automatically. One gotcha: with ASP.NET Core integration enabled, FromBody exists in two namespaces—Microsoft.Azure.Functions.Worker.Http and Microsoft.AspNetCore.Mvc. You need the Functions Worker version. The using alias at the top of the file resolves the ambiguity so you can use [FromBody] without a fully qualified name.
Route = "orders" overrides the default route. Without it, the URL would be /api/CreateOrder (derived from the function name). With it, the URL becomes /api/orders—cleaner and more RESTful.
CreatedResult returns HTTP 201 with a Location header pointing to the new resource. Standard REST semantics.
Test it:
curl -X POST http://localhost:7071/api/orders \
-H "Content-Type: application/json" \
-d '{"productId":"SKU-001","quantity":3}'
# → {"productId":"SKU-001","quantity":3}
# (with 201 status and Location header)
The Content-Type: application/json header is required. Without it, model binding won't kick in and you'll get a deserialization error.
Pattern 3: Route Parameters
For resource-oriented APIs, you'll want parameters embedded in the URL path rather than the query string:
public class ProductFunction(ILogger<ProductFunction> logger)
{
[Function("GetProduct")]
public IActionResult GetProduct(
[HttpTrigger(AuthorizationLevel.Function, "get",
Route = "products/{category:alpha}/{id:int?}")] HttpRequest req,
string category, int? id)
{
logger.LogInformation("Looking up {Category}, id={Id}", category, id);
return new OkObjectResult(new { category, id });
}
}
Route parameters are defined in curly braces inside the Route string and captured as method parameters matched by name. The runtime extracts category and id from the URL and passes them directly to your method.
Route constraints validate parameters before your code runs:
| Constraint | Meaning | Example |
|---|---|---|
:alpha |
Letters only |
electronics matches, 123 doesn't |
:int |
Integer |
42 matches, abc doesn't |
:int? |
Optional integer | Parameter can be omitted |
:guid |
GUID format | {id:guid} |
:length(5) |
Exact string length | {code:length(5)} |
:min(1) |
Minimum integer value | {page:min(1)} |
If a constraint fails, the runtime returns a 404—your function never executes.
Test it:
curl http://localhost:7071/api/products/electronics/42
# → {"category":"electronics","id":42}
curl http://localhost:7071/api/products/electronics
# → {"category":"electronics","id":null}
curl http://localhost:7071/api/products/123/42
# → 404 (constraint :alpha rejects "123")
You now have three working patterns that cover most real-world HTTP function scenarios.
Running and Testing Locally
Restart func start now that all three functions are in place. You should see all of them listed:
Azure Functions Core Tools
Core Tools Version: 4.6.0
Functions:
CreateOrder: [POST] http://localhost:7071/api/orders
GetProduct: [GET] http://localhost:7071/api/products/{category:alpha}/{id:int?}
Hello: [GET] http://localhost:7071/api/Hello
For quick reference, here are all three test commands together:
# Pattern 1: GET with query string
curl "http://localhost:7071/api/Hello?name=Azure"
# Pattern 2: POST with JSON body
curl -X POST http://localhost:7071/api/orders \
-H "Content-Type: application/json" \
-d '{"productId":"SKU-001","quantity":3}'
# Pattern 3: Route parameters
curl http://localhost:7071/api/products/electronics/42
You can also use Postman, the REST Client extension for VS Code, or HTTPie—whatever fits your workflow.
A note on authorization: when running locally, AuthorizationLevel.Function is bypassed. You don't need to pass a function key. This makes local development frictionless. Once deployed, clients will need to include the key as a query parameter (?code=<key>) or in the x-functions-key header.
Troubleshooting
If things don't work on the first try, check these common issues:
"Can't determine project language" or "No job functions found"
The .csproj file is missing required settings. Verify it targets net10.0, has <AzureFunctionsVersion>v4</AzureFunctionsVersion>, and includes the Worker SDK packages. Run dotnet build separately to check for compilation errors.
Port 7071 already in use
Another Functions host (or another process) is using the default port. Either kill it or start on a different port:
func start --port 7072
"Value cannot be null: provider" or storage connection errors
Azurite isn't running. The Functions runtime needs a storage emulator even for HTTP triggers. Start Azurite, or verify that local.settings.json contains:
{
"IsEncrypted": false,
"Values": {
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated"
}
}
UseDevelopmentStorage=true tells the SDK to connect to Azurite on its default ports.
JSON deserialization errors on POST requests
Two common causes: missing the Content-Type: application/json header, or property name mismatches between your JSON and your C# record. By default, the serializer is case-insensitive, so productId and ProductId both work. But the property names must exist on the target type.
"The listener for function X was unable to start"
Usually a runtime version mismatch. Confirm func --version returns 4.x and your .csproj has <AzureFunctionsVersion>v4</AzureFunctionsVersion>. If you recently upgraded the Core Tools, also run dotnet clean and rebuild.
Conclusion
We went from an empty directory to a running HTTP API with three production patterns:
-
Query strings for simple reads (
GET /api/Hello?name=Azure) -
JSON bodies for mutations (
POST /api/orders) -
Route parameters for resource-oriented endpoints (
GET /api/products/electronics/42)
All running locally, no Azure subscription needed.
The isolated worker model with ASP.NET Core integration gives you familiar types—HttpRequest, IActionResult, [FromBody]—so writing Azure Functions feels like writing any other .NET web API. The main difference is that Azure handles the hosting, scaling, and infrastructure.
The code samples in this article are deliberately simple. In a real project, you'd add input validation, error handling, and probably connect to a database or external service. But the patterns are the same—the [HttpTrigger] attribute, the route configuration, the model binding. Once you understand these building blocks, everything else is regular C#.
All code from this article is available in the azure-functions-samples repository—clone it, run func start, and experiment.
What's Next in This Series
Part 3: Beyond HTTP Triggers (coming soon) covers timer triggers for scheduled jobs, queue triggers for message processing, and blob triggers for reacting to file uploads. Same project structure, new event sources.
Part 4: Local Development Setup (coming soon) digs into the day-to-day workflow—debugging in VS Code, hot reload, and productivity tips.
Part 5: Understanding the Isolated Worker Model (coming soon) explains what "isolated" means, why Microsoft created this model, how it differs from the legacy in-process model, and what it means for your code.
And yes—we'll cover deploying to Azure with CI/CD via GitHub Actions later in the series, so everything you're building locally will make it to the cloud.
Azure Functions for .NET Developers Series
- Part 1: Why Azure Functions? Serverless for .NET Developers
- You are here: Part 2: Your First Azure Function: HTTP Triggers Step-by-Step
- Part 3: Beyond HTTP Triggers (coming soon)
Top comments (0)