Skip to content
Merged
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
13 changes: 13 additions & 0 deletions .config/dotnet-tools.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-outdated-tool": {
"version": "4.6.9",
"commands": [
"dotnet-outdated"
],
"rollForward": false
}
}
}
9 changes: 5 additions & 4 deletions Directory.Build.props
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project>
<!-- See https://aka.ms/dotnet/msbuild/customize for more details on customizing your build -->
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
Expand All @@ -16,7 +16,7 @@
<PropertyGroup>
<Product>Hare</Product>
<Company>Hare Contributors</Company>
<Copyright>Copyright © 2024 Wannes Gennar</Copyright>
<Copyright>Copyright © 2026 Wannes Gennar</Copyright>
<Description>A dead-simple, fast, and lightweight .NET messaging library for RabbitMQ.</Description>
<PackageProjectUrl>https://github.com/dealloc/hare</PackageProjectUrl>
<RepositoryUrl>https://github.com/dealloc/hare</RepositoryUrl>
Expand All @@ -37,7 +37,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="8.0.0" PrivateAssets="All" />
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="10.0.102" PrivateAssets="All" />
</ItemGroup>

<ItemGroup Condition="'$(IsPackable)' == 'true'">
Expand All @@ -46,6 +46,7 @@

<!-- Automatically expose internals to test projects -->
<ItemGroup Condition="!$(MSBuildProjectName.EndsWith('.Tests'))">
<InternalsVisibleTo Include="$(MSBuildProjectName).Tests" />
<InternalsVisibleTo Include="$(MSBuildProjectName).UnitTests" />
<InternalsVisibleTo Include="$(MSBuildProjectName).IntegrationTests" />
</ItemGroup>
</Project>
8 changes: 8 additions & 0 deletions Hare.slnx
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
<Solution>
<Folder Name="/samples/">
<Project Path="samples/Hare.Samples.ServiceDefaults\Hare.Samples.ServiceDefaults.csproj" />
<Project Path="samples/Hare.Samples.AppHost/Hare.Samples.AppHost.csproj" />
<Project Path="samples/Hare.Samples.ExampleConsole/Hare.Samples.ExampleConsole.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/Hare.UnitTests/Hare.UnitTests.csproj" />
</Folder>
<Project Path="src\Hare\Hare.csproj" />
</Solution>
226 changes: 166 additions & 60 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,13 @@ Hare is for you if:
## Features

- **Fully AOT compatible** - Works with [Native AOT](https://learn.microsoft.com/en-us/dotnet/core/deploying/native-aot/) compilation
- **Dead-letter queue support** - Automatic DLQ configuration and failed message routing
- **Dead-letter queue support** - Automatic DLQ provisioning with conventional naming and configurable retry
- **Distributed tracing** - Built-in OpenTelemetry support with correlation ID propagation
- **Aspire integration** - Works seamlessly with .NET Aspire for cloud-native development
- **Type-safe messaging** - Leverage generics for compile-time message type safety
- **Minimal dependencies** - Only depends on RabbitMQ.Client, OpenTelemetry, and Microsoft.Extensions abstractions
- **Minimal dependencies** - Only depends on RabbitMQ.Client and Microsoft.Extensions abstractions
- **Conventional routing** - Automatic exchange/queue naming based on message types
- **Auto-provisioning** - Automatic creation of exchanges, queues, and bindings

## Installation

Expand Down Expand Up @@ -53,7 +55,7 @@ Configure and send messages from your producer application:
using Hare.Extensions;
using Hare.Contracts;

var builder = WebApplication.CreateBuilder(args);
var builder = Host.CreateApplicationBuilder(args);

// Add RabbitMQ connection
builder.Services.AddSingleton<IConnection>(sp =>
Expand All @@ -65,30 +67,33 @@ builder.Services.AddSingleton<IConnection>(sp =>
// Or, if you're using .NET Aspire
builder.AddRabbitMQClient("rabbitmq");

// Register Hare with OpenTelemetry support and configure message type
// Register Hare with the fluent builder API
builder.Services
.AddHare()
.AddHareMessage<OrderPlacedMessage>(config =>
{
config.Exchange = "orders";
config.QueueName = "orders.placed";
config.Durable = true;
config.JsonTypeInfo = MessageSerializerContext.Default.OrderPlacedMessage;
}, listen: false);

var app = builder.Build();

// Use the message sender
app.MapPost("/orders", async (
OrderPlacedMessage message,
IMessageSender<OrderPlacedMessage> sender,
CancellationToken ct) =>
{
await sender.SendMessageAsync(message, ct);
return Results.Ok();
});
.WithConventionalRouting() // Use default routing conventions
.WithAutoProvisioning() // Automatically create exchanges/queues
.WithJsonSerializerContext(MessageSerializerContext.Default)
.AddHareMessage<OrderPlacedMessage>(); // Register message for sending

var host = builder.Build();

// Provision exchanges and queues before starting
await host.RunHareProvisioning(CancellationToken.None);

host.Run();
```

app.Run();
To send messages, inject `IMessageSender<TMessage>`:

```csharp
public class OrderService(IMessageSender<OrderPlacedMessage> sender)
{
public async Task PlaceOrderAsync(Order order, CancellationToken ct)
{
var message = new OrderPlacedMessage(order.Id, order.CustomerId, order.Amount);
await sender.SendAsync(message, ct);
}
}
```

### 3. Consuming Messages
Expand All @@ -101,73 +106,141 @@ using Hare.Contracts;

var builder = Host.CreateApplicationBuilder(args);

// Add RabbitMQ connection
builder.Services.AddSingleton<IConnection>(sp =>
{
var factory = new ConnectionFactory { HostName = "localhost" };
return factory.CreateConnectionAsync().GetAwaiter().GetResult();
});

// Or, if you're using .NET Aspire
// Add RabbitMQ connection (same as producer)
builder.AddRabbitMQClient("rabbitmq");

// Register Hare, configure message type, and register handler
// Register Hare with message handler
builder.Services
.AddHare()
.AddHareMessage<OrderPlacedMessage>(config =>
{
config.Exchange = "orders";
config.QueueName = "orders.placed";
config.Durable = true;
config.DeadletterExchange = "orders.dead-letter";
config.DeadletterQueueName = "orders.placed.dead-letter";
config.JsonTypeInfo = MessageSerializerContext.Default.OrderPlacedMessage;
}, listen: true)
.AddScoped<IMessageHandler<OrderPlacedMessage>, OrderPlacedHandler>();
.WithConventionalRouting()
.WithAutoProvisioning()
.WithJsonSerializerContext(MessageSerializerContext.Default)
.AddHareMessage<OrderPlacedMessage, OrderPlacedHandler>(); // Register with handler

var host = builder.Build();
await host.RunHareProvisioning(CancellationToken.None);
host.Run();
```

### 4. Implement Message Handler

```csharp
using Hare.Contracts;
using Hare.Models;

public class OrderPlacedHandler(ILogger<OrderPlacedHandler> logger) : IMessageHandler<OrderPlacedMessage>
{
public async ValueTask HandleAsync(OrderPlacedMessage message, CancellationToken cancellationToken)
public ValueTask HandleAsync(
OrderPlacedMessage message,
MessageContext context,
CancellationToken cancellationToken)
{
logger.LogInformation("Processing order {OrderId} for customer {CustomerId}",
message.OrderId, message.CustomerId);

// Your business logic here
await Task.CompletedTask;
// Access message metadata via context
// context.Redelivered, context.Exchange, context.RoutingKey, context.Properties

return ValueTask.CompletedTask;
}
}
```

## Fluent Configuration API

Hare uses a fluent builder pattern for configuration:

### Global Configuration

```csharp
builder.Services
.AddHare()
.WithConventionalRouting() // Enable default routing conventions
.WithConventionalRouting<MyConvention>() // Or use custom routing convention
.WithAutoProvisioning() // Auto-create exchanges/queues
.WithJsonSerializerContext(context); // Add JSON type info for AOT
```

### Per-Message Configuration

```csharp
builder.Services
.AddHare()
.WithConventionalRouting()
.AddHareMessage<OrderMessage, OrderHandler>()
.WithQueue("orders-queue") // Override queue name
.WithExchange("orders", "direct") // Override exchange
.WithRoutingKey("orders.placed") // Override routing key
.WithConcurrency(4) // Number of concurrent listeners
.WithDeadLetterExchange("orders.dlx") // Override DLX name
.WithDeadLetterRoutingKey("orders.failed") // Override DLQ routing key
.WithAutoProvisioning(false); // Disable auto-provisioning for this message
```

## Conventional Routing

When `WithConventionalRouting()` is enabled, Hare automatically derives routing configuration from message type names:

- **Queue name**: Message type name in kebab-case (e.g., `OrderPlacedMessage` → `order-placed-message`)
- **Routing key**: Same as queue name
- **Exchange**: Entry assembly name in kebab-case
- **Exchange type**: `direct`
- **Dead-letter exchange**: `{exchange}.dlx`
- **Dead-letter queue**: `{queue}.dlq`

You can override any convention per-message using the fluent builder methods.

## Dead-Letter Queue Support

Hare automatically handles failed message processing with dead-letter queues:
Hare provides built-in dead-letter queue (DLQ) support with automatic provisioning. Dead-lettering is **enabled by default** when using conventional routing.

### How It Works

- **First failure**: Message is nacked and requeued for retry
- **Second failure**: Message is nacked without requeue and routed to the dead-letter exchange

### Conventional DLQ Naming

1. **Automatic DLQ Setup** - Both producer and consumer create necessary exchanges and queues
2. **Requeue Logic** - Failed messages are requeued once, then routed to DLQ
3. **Configuration** - Both `DeadletterExchange` and `DeadletterQueueName` must be set together
When using `WithConventionalRouting()`, Hare automatically generates DLQ names:

- **Dead-letter exchange**: `{exchange-name}.dlx`
- **Dead-letter queue**: `{queue-name}.dlq`
- **Exchange type**: `direct`

For example, a message type `OrderPlacedMessage` in assembly `MyApp` would get:
- DLX: `my-app.dlx`
- DLQ: `order-placed-message.dlq`

### Custom DLQ Configuration

Override the conventional naming per-message:

```csharp
config.DeadletterExchange = "orders.dead-letter";
config.DeadletterQueueName = "orders.placed.dead-letter";
builder.Services
.AddHare()
.WithConventionalRouting()
.AddHareMessage<OrderMessage, OrderHandler>()
.WithDeadLetterExchange("orders.dlx", "direct")
.WithDeadLetterRoutingKey("orders.failed");
```

When a message handler throws an exception:
- **First failure**: Message is nacked and requeued
- **Second failure**: Message is sent to the dead-letter queue
### Disabling Dead-Lettering

To disable dead-lettering for a specific message type:

```csharp
.AddHareMessage<TransientMessage, TransientHandler>()
.WithDeadLetter(false);
```

## OpenTelemetry & Distributed Tracing

Hare includes built-in OpenTelemetry support with automatic correlation ID propagation:

```csharp
builder.Services.AddHare(); // Adds "Hare" ActivitySource
// Add Hare's ActivitySource to your tracing configuration
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing.AddSource("Hare.*"));

// The library automatically:
// - Sets correlation ID from Activity.Current?.Id when publishing
Expand All @@ -177,6 +250,21 @@ builder.Services.AddHare(); // Adds "Hare" ActivitySource

This integrates seamlessly with .NET Aspire's dashboard for end-to-end tracing.

## Auto-Provisioning

When `WithAutoProvisioning()` is enabled, Hare automatically creates the required RabbitMQ resources before your application starts:

```csharp
var host = builder.Build();

// This creates exchanges, queues, and bindings
await host.RunHareProvisioning(CancellationToken.None);

host.Run();
```

You can enable/disable auto-provisioning globally or per-message type.

## AOT Compatibility

Hare is fully compatible with Native AOT compilation. To ensure AOT compatibility:
Expand All @@ -185,21 +273,39 @@ Hare is fully compatible with Native AOT compilation. To ensure AOT compatibilit

```csharp
[JsonSerializable(typeof(OrderPlacedMessage))]
[JsonSerializable(typeof(CustomerCreatedMessage))]
public partial class MessageSerializerContext : JsonSerializerContext { }
```

2. **Provide `JsonTypeInfo`** when configuring messages:
2. **Register the context** with Hare:

```csharp
config.JsonTypeInfo = MessageSerializerContext.Default.OrderPlacedMessage;
builder.Services
.AddHare()
.WithJsonSerializerContext(MessageSerializerContext.Default);
```

3. **Use records for immutable messages** as shown in the examples above
3. **Use records** for immutable messages as shown in the examples above

## MessageContext

The `MessageContext` struct provides access to message metadata in your handlers:

```csharp
public readonly struct MessageContext
{
public bool Redelivered { get; } // Whether this is a redelivery
public string Exchange { get; } // Source exchange name
public string RoutingKey { get; } // Routing key used
public IReadOnlyBasicProperties Properties { get; } // RabbitMQ properties
public ReadOnlyMemory<byte> Payload { get; } // Raw message bytes
}
```

## License

[MIT Licensed](./LICENSE.md)

## Contributing

Contributions are welcome! Please feel free to submit a Pull Request.
Contributions are welcome! Please see [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines.
6 changes: 5 additions & 1 deletion global.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
{
"sdk": {
"version": "9.0.306"
"version": "10.0.101",
"rollForward": "latestMinor"
},
"test": {
"runner": "Microsoft.Testing.Platform"
}
}
Loading