Skip to content

Add extensibility article on how to write non-container custom resources #3850

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/extensibility/custom-hosting-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -487,5 +487,8 @@ In the custom resource tutorial, you learned how to create a custom .NET Aspire

## Next steps

> [!div class="nextstepaction"]
> [Create non-container custom resources](custom-non-container-resource.md)

> [!div class="nextstepaction"]
> [Create custom .NET Aspire client integrations](custom-client-integration.md)
145 changes: 145 additions & 0 deletions docs/extensibility/custom-non-container-resource.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
---
title: Create non-container custom resources
description: Learn how to create custom .NET Aspire resources that don't rely on containers using lifecycle hooks and dashboard integration.
ms.date: 06/18/2025
ms.topic: how-to
---

# Create non-container custom resources

While many .NET Aspire resources are container-based, you can also create custom resources that run in-process or manage external services without containers. This article shows how to build a non-container custom resource that integrates with the Aspire dashboard using lifecycle hooks, status notifications, and logging.

## When to use non-container resources

Before creating a custom resource, consider whether your scenario might be better served by simpler approaches:

- **Connection strings only**: If you just need to connect to an external service, <xref:Aspire.Hosting.ParameterResourceBuilderExtensions.AddConnectionString*> might suffice.
- **Configuration values**: For simple configuration, <xref:Aspire.Hosting.ParameterResourceBuilderExtensions.AddParameter*> might be enough.

Custom non-container resources are valuable when you need:

- Dashboard integration with status updates and logs
- Lifecycle management (starting/stopping services)
- In-process services that benefit from Aspire's orchestration
- External resource management with rich feedback

Examples include:

- In-process HTTP proxies or middleware
- Cloud service provisioning and management
- External API integrations with health monitoring
- Background services that need dashboard visibility

## Key components

Non-container custom resources use these key Aspire services:

- **<xref:Aspire.Hosting.Lifecycle.IDistributedApplicationLifecycleHook>**: Hook into app startup/shutdown. For more information, see [App Host life cycle events](../app-host/eventing.md#app-host-life-cycle-events).
- **<xref:Microsoft.Extensions.Logging.ILogger>**: Standard .NET logging that appears in console and dashboard

> [!NOTE]
> Advanced dashboard integration is possible using services like `ResourceNotificationService` and `ResourceLoggerService` for real-time status updates and log streaming. These APIs provide richer dashboard experiences but require more complex implementation.

## Example: HTTP proxy resource

This example creates an in-process HTTP proxy resource that demonstrates the core concepts of lifecycle management and logging integration with the Aspire dashboard.

### Define the resource

First, create the resource class:

:::code language="csharp" source="snippets/HttpProxyResource/HttpProxy.Hosting/HttpProxyResource.cs":::

The resource implements <xref:Aspire.Hosting.ApplicationModel.IResource> and includes properties for the proxy configuration.

### Create the extension method

Next, create the builder extension:

:::code language="csharp" source="snippets/HttpProxyResource/HttpProxy.Hosting/HttpProxyResourceBuilderExtensions.cs":::

This extension method adds the resource to the application model and configures an HTTP endpoint.

### Implement lifecycle management

Create a lifecycle hook to manage the proxy:

:::code language="csharp" source="snippets/HttpProxyResource/HttpProxy.Hosting/HttpProxyLifecycleHook.cs":::

The lifecycle hook:

1. **Manages lifecycle**: Starts services when resources are created
2. **Integrates logging**: Uses standard .NET logging that appears in the Aspire dashboard
3. **Handles background tasks**: Runs long-running services in background tasks
4. **Provides resource management**: Manages resources like HTTP listeners and cleanup

### Register the lifecycle hook

The extension method automatically registers the lifecycle hook:

```csharp
public static IResourceBuilder<HttpProxyResource> AddHttpProxy(
this IDistributedApplicationBuilder builder,
string name,
string targetUrl,
int? port = null)
{
var resource = new HttpProxyResource(name, targetUrl);

// Register the lifecycle hook for this resource type
builder.Services.TryAddSingleton<HttpProxyLifecycleHook>();
builder.Services.AddLifecycleHook<HttpProxyLifecycleHook>();

return builder.AddResource(resource)
.WithHttpEndpoint(port: port, name: "http");
}
```

### Use the resource

Now you can use the proxy in your app host:

:::code language="csharp" source="snippets/HttpProxyResource/HttpProxySample.AppHost/Program.cs":::

## Dashboard integration

The resource integrates with the Aspire dashboard through:

### Standard logging

Use standard .NET logging patterns that automatically appear in the dashboard:

```csharp
_logger.LogInformation("Starting HTTP proxy {ResourceName} -> {TargetUrl}",
resource.Name, resource.TargetUrl);
_logger.LogError(ex, "Failed to start HTTP proxy {ResourceName}", resource.Name);
```

### Advanced dashboard features

For more sophisticated dashboard integration, you can use:

- **Status notifications**: Update resource state and properties in real-time
- **Log streaming**: Send structured logs directly to the dashboard
- **Health monitoring**: Report resource health and performance metrics

These advanced features require additional Aspire hosting APIs and more complex implementation patterns.

## Best practices

When creating non-container resources:

1. **Resource cleanup**: Always implement proper disposal in lifecycle hooks
2. **Error handling**: Catch and log exceptions, update status appropriately
3. **Status updates**: Provide meaningful status information to users
4. **Performance**: Avoid blocking operations in lifecycle methods
5. **Dependencies**: Use dependency injection for required services

## Summary

Non-container custom resources extend .NET Aspire beyond containers to include in-process services and external resource management. By implementing lifecycle hooks and integrating with the dashboard through status notifications and logging, you can create rich development experiences for any type of resource your application needs.

## Next steps

> [!div class="nextstepaction"]
> [Create custom .NET Aspire client integrations](custom-client-integration.md)
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">

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

<ItemGroup>
<PackageReference Include="Aspire.Hosting" Version="8.0.0" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
using System.Net;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Lifecycle;
using Microsoft.Extensions.Logging;

namespace HttpProxy.Hosting;

/// <summary>
/// Lifecycle hook that manages HTTP proxy resources.
/// </summary>
public class HttpProxyLifecycleHook : IDistributedApplicationLifecycleHook
{
private readonly ILogger<HttpProxyLifecycleHook> _logger;
private readonly Dictionary<string, HttpListener> _listeners = new();

public HttpProxyLifecycleHook(ILogger<HttpProxyLifecycleHook> logger)
{
_logger = logger;
}

public Task BeforeStartAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}

public Task AfterEndpointsAllocatedAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}

public Task AfterResourcesCreatedAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default)
{
// Find and start HTTP proxy resources
var proxyResources = appModel.Resources.OfType<HttpProxyResource>();

foreach (var resource in proxyResources)
{
StartProxy(resource);
}

return Task.CompletedTask;
}

private void StartProxy(HttpProxyResource resource)
{
try
{
_logger.LogInformation("Starting HTTP proxy {ResourceName} -> {TargetUrl}",
resource.Name, resource.TargetUrl);

// Create and start HTTP listener on a dynamic port
var listener = new HttpListener();
listener.Prefixes.Add("http://localhost:0/"); // Use system-assigned port
listener.Start();

_listeners[resource.Name] = listener;

// Start processing requests in the background
_ = Task.Run(() => ProcessRequests(resource, listener));

_logger.LogInformation("HTTP proxy {ResourceName} started successfully", resource.Name);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to start HTTP proxy {ResourceName}", resource.Name);
}
}

private async Task ProcessRequests(HttpProxyResource resource, HttpListener listener)
{
var requestCount = 0;

while (listener.IsListening)
{
try
{
var context = await listener.GetContextAsync();
requestCount++;

_logger.LogInformation("Proxy {ResourceName} handling request {RequestCount}: {Method} {Path}",
resource.Name, requestCount, context.Request.HttpMethod, context.Request.Url?.PathAndQuery);

// Simple response for demonstration
var response = context.Response;
response.StatusCode = 200;
var responseString = $"Proxy {resource.Name} would forward to {resource.TargetUrl}";
var buffer = System.Text.Encoding.UTF8.GetBytes(responseString);
await response.OutputStream.WriteAsync(buffer);
response.Close();
}
catch (HttpListenerException)
{
// Listener was stopped
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing request in proxy {ResourceName}", resource.Name);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using Aspire.Hosting.ApplicationModel;

namespace HttpProxy.Hosting;

/// <summary>
/// Represents an HTTP proxy resource that forwards requests to a target URL.
/// </summary>
/// <param name="name">The name of the resource.</param>
/// <param name="targetUrl">The target URL to proxy requests to.</param>
public class HttpProxyResource(string name, string targetUrl) : Resource(name), IResourceWithEndpoints
{
/// <summary>
/// Gets the target URL that requests will be proxied to.
/// </summary>
public string TargetUrl { get; } = targetUrl;

/// <summary>
/// Gets the name of the HTTP endpoint.
/// </summary>
public const string HttpEndpointName = "http";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Lifecycle;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;

namespace Aspire.Hosting;

/// <summary>
/// Extension methods for adding HTTP proxy resources to the application model.
/// </summary>
public static class HttpProxyResourceBuilderExtensions
{
/// <summary>
/// Adds an HTTP proxy resource to the application model.
/// </summary>
/// <param name="builder">The distributed application builder.</param>
/// <param name="name">The name of the resource.</param>
/// <param name="targetUrl">The target URL to proxy requests to.</param>
/// <param name="port">The port to listen on (optional).</param>
/// <returns>A resource builder for the HTTP proxy resource.</returns>
public static IResourceBuilder<HttpProxy.Hosting.HttpProxyResource> AddHttpProxy(
this IDistributedApplicationBuilder builder,
string name,
string targetUrl,
int? port = null)
{
var resource = new HttpProxy.Hosting.HttpProxyResource(name, targetUrl);

// Register the lifecycle hook for this resource type
builder.Services.TryAddSingleton<HttpProxy.Hosting.HttpProxyLifecycleHook>();
builder.Services.AddLifecycleHook<HttpProxy.Hosting.HttpProxyLifecycleHook>();

return builder.AddResource(resource)
.WithHttpEndpoint(port: port, name: HttpProxy.Hosting.HttpProxyResource.HttpEndpointName);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using Aspire.Hosting;

var builder = DistributedApplication.CreateBuilder(args);

// Add an HTTP proxy that forwards requests to an external API
var proxy = builder.AddHttpProxy("api-proxy", "https://jsonplaceholder.typicode.com", port: 5100);

// Add a web project that can use the proxy
var webapp = builder.AddProject<Projects.WebApp>("webapp")
.WithReference(proxy);

builder.Build().Run();
3 changes: 3 additions & 0 deletions docs/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,9 @@ items:
- name: Create hosting integrations
displayName: resources,extensibility,hosting,integrations
href: extensibility/custom-hosting-integration.md
- name: Create non-container custom resources
displayName: resources,extensibility,hosting,integrations,lifecycle,dashboard
href: extensibility/custom-non-container-resource.md
- name: Create client integrations
displayName: custom,extensibility,client,library,integrations
href: extensibility/custom-client-integration.md
Expand Down
Loading