Cloudflare’s recent global outage, linked to a database update, caused widespread disruption and highlighted the risks of single-vendor reliance. While service was restored, the incident sparked discussions on the importance of multi-vendor strategies in tech. Cloudflare's CEO vowed to enhance system resilience, emphasizing that outages can impact even the largest providers.
Precision issues in financial and time-based systems rarely appear as obvious failures. They show up as small inconsistencies—pennies lost in calculations, off-by-one billing intervals, timestamps that shift unexpectedly when converted between time zones. At first, these problems seem harmless. But in real enterprise environments, where millions of operations happen daily, these “minor” inconsistencies accumulate into financial losses, audit discrepancies, and customer complaints.
The root cause is predictable: developers rely on primitive types like double and DateTime because they are easy to work with. They’re familiar, they compile, and they often appear to “work fine” during early testing. But these primitives do not model financial or temporal concepts accurately. When dealing with cents, tax percentages, billing cycles, or UTC instants, convenience becomes a source of long-term risk.
This section explains why imprecision is not just a theoretical math problem. It is a systemic issue that affects financial software, subscription services, logistics systems, and anything governed by regulatory or audit requirements. To fix it, we must move away from loosely defined primitives and toward semantic types that express meaning, intent, and domain rules directly.
In last week blog post, we saw how to extend an AI agents intelligence using function tools.
In this post, we see how you build AI agents and advertise them as function tools to other agents . There are a few reasons why you might want to do this.
In this blog post we cover:
why implement an agent as function tool
how to implement an agent as function tool
creating a nutrition agent that can find protein rich foods
extending a personal trainer ai agent use the nutritionist agent
implementing the nutrition agent as a function tool
testing personal trainer ai agent new capabilities
A video demo and full code examples are included.
~
Why Implement an Agent as Function Tool
You might want to use this pattern to isolate behaviours and responsibilities during your AI agent development.
With this approach, you can build multiple agents, use them individually, or augment an existing AI agent’s capability with behaviours from other agents.
Applying this approach removes the risk of developing monolithic agents.
Creating discrete agents and advertising them as function tools makes it easier to design, build, maintain, and observe your AI agents.
~
How to Implement an Agent as Function Tool
To implement an agent as function tool you use the AsAIFunction method and call this from your parent agent.
You can see an example of this here:
AIAgent agent = new OpenAIClient(apiKey)
.GetChatClient(model)
.CreateAIAgent(instructions: "You are a personal trainer." +
"You can answer questions related to health, fitness and 5x5 stronglift. " +
"You should only use function tools in the PersonalTrainerAgent or NutritionAgent.",
name: "IronMind AI", null,
tools: [AIFunctionFactory.Create(PersonalTrainerAgent.GetNextAvailableDate),
AIFunctionFactory.Create(PersonalTrainerAgent.CancelAppointment),
AIFunctionFactory.Create(PersonalTrainerAgent.BookAppointment),
AIFunctionFactory.Create(PersonalTrainerAgent.ListBookedAppointments),
YOUR-ADDITIONAL-AGENT.AsAIFunction()]
);
Let’s see how we can apply this pattern to extend our original Iron Mind AI personal trainer agent.
~
An Example – Extending the Iron Mind AI Personal Trainer Agent
In an earlier blog post, we created 4 function tools and made them available to the Iron Mind AI personal trainer agent.
To recap, these were:
Get next available date for a booking
Make a booking
Cancel a booking
Get list of existing bookings
We want to extend the Iron Mind AI personal trainer agent to handle more than booking sessions. We want to introduce function tools that let people:
Search and filter for high protein recipes
Learn how to cook and prepare each recipe
Examine key nutrients and macros
We can do this by creating a new Nutritionist Agent. Then, by using the .AsAIFunction() method, make this available to our Personal Trainer Agent as an additional function tool.
Before we do that though, a few things must be done:
public class Recipe
{
public string Title { get; set; } = string.Empty;
public string Url { get; set; } = string.Empty;
public List<string> Ingredients { get; set; } = new();
public List<string> Instructions { get; set; } = new();
public NutritionInfo? Nutrition { get; set; }
public string PrepTime { get; set; } = string.Empty;
public string CookTime { get; set; } = string.Empty;
public int Servings { get; set; }
}
public class NutritionInfo
{
public int Calories { get; set; }
public int ProteinGrams { get; set; }
public int CarbsGrams { get; set; }
public int FatGrams { get; set; }
}
Now we need a way to fetch and serialise data from the source to the above objects.
~
Creating a Nutrition Service to Search for Data
We can create a service class to encapsulate all core search, scraping and parsing logic.
We’ll call this NutritionService.
Rather than manually implement this, I used Claude to create most of this service class then manually removed code that was overkill.
The key methods in the NutritionService are:
SearchRecipesAsync – searches for recipes that match a search term
ScrapeRecipeAsync – fetches details for a recipe
Some utility methods are also included to help with scraping and parsing recipe HTML data to the Recipe and NutritionInfo objects we created earlier.
A completed definition for the NutritionService is provided:
public class NutritionService
{
private readonly HttpClient _httpClient;
private readonly HtmlWeb _htmlWeb;
private readonly int _delayMs;
public NutritionService(int delayMs = 1500)
{
_delayMs = delayMs;
_httpClient = new HttpClient();
_httpClient.DefaultRequestHeaders.Add("User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) RecipeBot/1.0");
_htmlWeb = new HtmlWeb();
}
/// <summary>
/// Searches Jamie Oliver's site and returns recipe URLs
/// </summary>
public async Task<List<string>> SearchRecipesAsync(string searchTerm)
{
var urls = new List<string>();
var searchUrl = $"https://www.jamieoliver.com/search/{Uri.EscapeDataString(searchTerm)}";
await Task.Delay(_delayMs);
var doc = await _htmlWeb.LoadFromWebAsync(searchUrl);
var linkNodes = doc.DocumentNode.SelectNodes("//a[contains(@href,'/recipes/')]");
if (linkNodes != null)
{
foreach (var linkNode in linkNodes)
{
var href = linkNode.GetAttributeValue("href", "");
if (!string.IsNullOrWhiteSpace(href))
{
if (href.StartsWith("/"))
href = $"https://www.jamieoliver.com{href}";
if (IsRecipeUrl(href) && !urls.Contains(href))
urls.Add(href);
}
}
}
Console.WriteLine($"Found {urls.Count} recipe URLs for search term '{searchTerm}'.");
return urls;
}
/// <summary>
/// Scrapes a single recipe from URL
/// </summary>
public async Task<Recipe?> ScrapeRecipeAsync(string url)
{
await Task.Delay(_delayMs);
var doc = await _htmlWeb.LoadFromWebAsync(url);
// Try JSON-LD first, fallback to HTML
return ParseJsonLdRecipe(doc, url) ?? ParseHtmlRecipe(doc, url);
}
/// <summary>
/// Filters recipes by minimum protein content
/// </summary>
public List<Recipe> FilterByProtein(List<Recipe> recipes, int minProteinGrams)
{
return recipes
.Where(r => r.Nutrition != null && r.Nutrition.ProteinGrams >= minProteinGrams)
.OrderByDescending(r => r.Nutrition!.ProteinGrams)
.ToList();
}
#region Parsing Helpers
private Recipe? ParseJsonLdRecipe(HtmlDocument doc, string url)
{
var scriptNodes = doc.DocumentNode.SelectNodes("//script[@type='application/ld+json']");
if (scriptNodes == null) return null;
foreach (var scriptNode in scriptNodes)
{
var json = scriptNode.InnerText;
if (!json.Contains("\"@type\":\"Recipe\"")) continue;
try
{
using var jsonDoc = JsonDocument.Parse(json);
var root = jsonDoc.RootElement;
var recipeElement = root.ValueKind == JsonValueKind.Array ? root[0] : root;
var recipe = new Recipe
{
Url = url,
Title = GetJsonString(recipeElement, "name"),
PrepTime = GetJsonString(recipeElement, "prepTime"),
CookTime = GetJsonString(recipeElement, "cookTime")
};
// Servings
if (recipeElement.TryGetProperty("recipeYield", out var yieldElement))
{
recipe.Servings = yieldElement.ValueKind == JsonValueKind.Number
? yieldElement.GetInt32()
: ExtractNumber(yieldElement.GetString() ?? "");
}
// Ingredients
if (recipeElement.TryGetProperty("recipeIngredient", out var ingredients))
{
foreach (var ingredient in ingredients.EnumerateArray())
recipe.Ingredients.Add(ingredient.GetString() ?? "");
}
// Instructions
if (recipeElement.TryGetProperty("recipeInstructions", out var instructions))
{
int step = 1;
foreach (var instruction in instructions.EnumerateArray())
{
string text = instruction.ValueKind == JsonValueKind.Object
? GetJsonString(instruction, "text")
: instruction.GetString() ?? "";
if (!string.IsNullOrWhiteSpace(text))
recipe.Instructions.Add($"{step++}. {text}");
}
}
// Nutrition
if (recipeElement.TryGetProperty("nutrition", out var nutrition))
{
recipe.Nutrition = new NutritionInfo
{
Calories = ExtractNumber(GetJsonString(nutrition, "calories")),
ProteinGrams = ExtractNumber(GetJsonString(nutrition, "proteinContent")),
CarbsGrams = ExtractNumber(GetJsonString(nutrition, "carbohydrateContent")),
FatGrams = ExtractNumber(GetJsonString(nutrition, "fatContent"))
};
}
return recipe;
}
catch (JsonException)
{
continue;
}
}
return null;
}
private Recipe? ParseHtmlRecipe(HtmlDocument doc, string url)
{
var recipe = new Recipe { Url = url };
// Title
var titleNode = doc.DocumentNode.SelectSingleNode("//h1");
recipe.Title = CleanText(titleNode?.InnerText ?? "Unknown Recipe");
// Ingredients
var ingredientNodes = doc.DocumentNode.SelectNodes(
"//ul[contains(@class,'ingred')]//li | //div[contains(@class,'ingredient')]//li");
if (ingredientNodes != null)
{
foreach (var node in ingredientNodes)
{
var ingredient = CleanText(node.InnerText);
if (!string.IsNullOrWhiteSpace(ingredient))
recipe.Ingredients.Add(ingredient);
}
}
// Instructions
var instructionNodes = doc.DocumentNode.SelectNodes(
"//ol[contains(@class,'method')]//li | //div[contains(@class,'method')]//p");
if (instructionNodes != null)
{
int step = 1;
foreach (var node in instructionNodes)
{
var instruction = CleanText(node.InnerText);
if (!string.IsNullOrWhiteSpace(instruction) && instruction.Length > 20)
recipe.Instructions.Add($"{step++}. {instruction}");
}
}
return recipe.Ingredients.Any() ? recipe : null;
}
private bool IsRecipeUrl(string url) =>
url.Contains("/recipes/") &&
!url.Contains("/recipes/category") &&
!url.Contains("/recipes/course") &&
!url.EndsWith("/recipes/");
private string CleanText(string text)
{
if (string.IsNullOrWhiteSpace(text)) return string.Empty;
text = HtmlEntity.DeEntitize(text);
text = Regex.Replace(text, @"\s+", " ");
return text.Trim();
}
private string GetJsonString(JsonElement element, string propertyName) =>
element.TryGetProperty(propertyName, out var prop) ? prop.GetString() ?? "" : "";
private int ExtractNumber(string text)
{
if (string.IsNullOrWhiteSpace(text)) return 0;
var match = Regex.Match(text, @"\d+");
return match.Success && int.TryParse(match.Value, out var number) ? number : 0;
}
#endregion
}
With the objects and service class in place, we can expose this functionality through a new agent -the Nutrition Agent.
~
Creating the Nutrition Agent and Design Considerations
This purpose of this agent is to allow people to ask for recipes that support dietary requirements when training with the 5 x 5 weight training programme.
The Nutrition Agent acts as a window into the capabilities of the Nutrition Service and makes these capabilities accessible from a natural language perspective.
Our service class does most of the heavy lifting.
You could argue the agent seems like an extra layer of abstraction over the core functionality in the Nutrition Service class.
This is a valid point. Not everything requires an AI solution.
You might not need to interact with a large language model or reason over code using natural language. In this example, we want to use natural language and assign an agent with task.
By exposing these features as an agent, it can be repurposed, leveraged as a function tool within its own right and easily integrated with large language models or other AI solutions.
The definition of the Nutrition Agent:
public class NutritionAgent
{
[Description("Searches for high protein recipes optimized for strength training. " +
"Returns recipes with detailed nutrition information. " +
"Best for finding recipes for 5x5 training, muscle building, or post-workout meals.")]
public static async Task<List<string>> SearchRecipesAsync([Description("Search term (e.g., 'chicken', 'beef', 'protein', 'breakfast')")] string searchTerm,
[Description("Minimum protein grams per serving (default 20)")] int minProtein = 20,
[Description("Maximum number of recipes to return (default 3)")] int maxResults = 3)
{
var service = new NutritionService();
Console.WriteLine($"Searching for recipes with term '{searchTerm}' and minimum {minProtein}g protein...");
var recipes = await service.SearchRecipesAsync(searchTerm);
var suitableRecipes = new List<Recipe>();
// Scrape each recipe for details
foreach (var recipeUrl in recipes)
{
Console.WriteLine($"Scraping recipe details from {recipeUrl}...");
var recipe = await service.ScrapeRecipeAsync(recipeUrl);
if(suitableRecipes.Count >= maxResults)
{
break;
}
if (recipe != null && recipe.Nutrition.ProteinGrams >= minProtein)
{
Console.WriteLine($"Found suitable recipe: {recipe.Title} with {recipe.Nutrition.ProteinGrams}g protein.");
suitableRecipes.Add(recipe);
}
}
return suitableRecipes.Select(r => JsonSerializer.Serialize(r)).ToList();
}
}
We could create an agent with following the code:
AIAgent nutritionAgent = new OpenAIClient(apiKey)
.GetChatClient(model)
.CreateAIAgent(instructions: "You can fetch recipes and nutrition data.",
name: "IronMind AI", null,
tools [AIFunctionFactory.Create(NutritionAgent.SearchRecipesAsync)]
);
But we want to see how to advertise the Nutrition Agent as an additional function tool from the main personal trainer agent.
~
Implementing the Nutrition Agent as a Function Tool
We can extend our original personal trainer agents intelligence by adding the Nutrition Agent as a function tool.
This is done by using the AsAIFunction method.
We can see this here:
AIAgent nutritionAgent = new OpenAIClient(apiKey)
.GetChatClient(model)
.CreateAIAgent(instructions: "You can fetch recipes and nutrition data.",
name: "IronMind AI", null,
tools: [AIFunctionFactory.Create(NutritionAgent.SearchRecipesAsync)]
);
AIAgent agent = new OpenAIClient(apiKey)
.GetChatClient(model)
.CreateAIAgent(instructions: "You are a personal trainer." +
"You can answer questions related to health, fitness and 5x5 stronglift. " +
"You shuld only use function tools in the PersonalTrainerAgent or NutritionAgent.",
name: "IronMind AI", null,
tools: [AIFunctionFactory.Create(PersonalTrainerAgent.GetNextAvailableDate),
AIFunctionFactory.Create(PersonalTrainerAgent.CancelAppointment),
AIFunctionFactory.Create(PersonalTrainerAgent.BookAppointment),
AIFunctionFactory.Create(PersonalTrainerAgent.ListBookedAppointments),
nutritionAgent.AsAIFunction()]
);
In the above code, we create the nutrition agent. Next, when the personal trainer agent is created, we pass in the Nutrition Agent but invoke the AsAIFunction method.
This lets you quickly extend the original agents capabilities.
You can use this pattern to build multiple agents, each with different responsibilities and daisy chain capabilities based on your requirements.
~
Testing Iron Mind AI Agent New Capabilities
We can now test the AI agent capabilities in a console application.
static async Task Main(string[] args)
{
AIAgent nutritionAgent = new OpenAIClient(apiKey)
.GetChatClient(model)
.CreateAIAgent(instructions: "You can fetch recipes and nutrition data.",
name: "IronMind AI", null,
tools: [AIFunctionFactory.Create(NutritionAgent.SearchRecipesAsync)]
);
AIAgent agent = new OpenAIClient(apiKey)
.GetChatClient(model)
.CreateAIAgent(instructions: "You are a personal trainer." +
"You can answer questions related to health, fitness and 5x5 stronglift. " +
"You should only use function tools in the PersonalTrainerAgent or NutritionAgent.",
name: "IronMind AI", null,
tools: [AIFunctionFactory.Create(PersonalTrainerAgent.GetNextAvailableDate),
AIFunctionFactory.Create(PersonalTrainerAgent.CancelAppointment),
AIFunctionFactory.Create(PersonalTrainerAgent.BookAppointment),
AIFunctionFactory.Create(PersonalTrainerAgent.ListBookedAppointments),
nutritionAgent.AsAIFunction()]
);
await RunChatLoopWithThreadAsync(agent);
}
First, we supply a prompt in natural language askin get me 3 recipes with at least 20g of protein in each.
The agent automatically maps the intent to our new nutrition service and attempts search for recipes:
After a few moments, 3 recipes are found:
Next, the agent provides us with the ingredients and cooking instructions:
### 1. Mushroom Stew
- **Protein:** 34g
- **Calories:** 580
- **Carbs:** 87g
- **Fat:** 9g
- **Servings:** 2
**Ingredients:**
- 250g mixed mushrooms
- 4 spring onions
- 2 carrots (160g)
- 5cm piece of ginger
- Olive oil
- 150g self-raising flour
- 1 x 400g tin of black beans
- 1 x 225g tin of sliced water chestnuts
- 160g beansprouts
- 2 tablespoons gochujang paste
- 150g silken tofu
- Optional: extra virgin olive oil
**Instructions:**
1. Fry mushrooms until nutty, then set aside.
2. Cook spring onions, carrots, and ginger with oil for 5 minutes.
3. Make dumpling dough and form balls.
4. Add beans, water chestnuts, and dumplings to the pan, cover, and cook for 10 minutes.
5. Season, add tofu, and drizzle with extra virgin olive oil before serving.
---
### 2. Toad in the Hole
- **Protein:** 27g
- **Calories:** 677
- **Carbs:** 44g
- **Fat:** 44g
- **Servings:** 4
**Ingredients:**
- Sunflower oil
- 8 higher-welfare sausages
- 4 sprigs of fresh rosemary
- 2 large red onions
- 2 cloves of garlic
- 2 knobs of unsalted butter
- 6 tablespoons balsamic vinegar
- 1 level tablespoon vegetable stock powder or cube
- **Batter:**
- 285ml milk
- 115g plain flour
- 3 large free-range eggs
**Instructions:**
1. Whisk batter ingredients and set aside.
2. Heat oil in a baking tin in a hot oven.
3. Add sausages and brown, then pour batter over.
4. Bake until golden and crisp.
5. For gravy, fry onions and garlic, add balsamic vinegar and stock, simmer, and serve.
---
### 3. Jamie's Classic Family Lasagne
- **Protein:** 28g
- **Calories:** 420
- **Carbs:** 35g
- **Fat:** 18g
- **Servings:** 12
**Ingredients:**
- 2 sprigs of fresh rosemary
- 100g higher-welfare smoked streaky bacon
- Olive oil
- 1kg quality minced beef
- 1kg higher-welfare minced pork
- 4 carrots
- 2 onions
- 4 sticks of celery
- 2 heaped tablespoons tomato purée
- 4 x 400g tins plum tomatoes
- 350g dried lasagne sheets
- **White Sauce:**
- 150g mature Cheddar cheese
- 2 medium leeks
- 2 fresh bay leaves
- 4 tablespoons plain flour
- 1 litre semi-skimmed milk
- 1 whole nutmeg, for grating
**Instructions:**
1. Fry rosemary and bacon, then add minced meats.
2. Add chopped vegetables and cook until softened.
3. Mix in tomatoes and simmer until thickened.
4. Make white sauce by cooking leeks and then adding flour and milk.
5. Layer lasagne in a dish, top with cheese, and bake until golden.
These recipes provide ample protein and are nutritious options for a healthy diet! Enjoy your cooking!
Perfect.
We have given our original agent a new capability. It can now manage bookings and handle nutrition requests.
~
Demo
In this demo, we can see the above in action. We supply the prompt and the AI agent automatically invokes our new nutrition capabilities.
~
Summary
In this blog post, we’ve seen how agents can be advertised as function tools to another agent.
We saw how to extend or Iron Mind AI personal trainer agent and give it access to capabilities contained within another agent.
You might use this pattern to isolate behaviours and responsibilities during your AI agent development.
In the next blog post, we’ll look at how to add human-in-the-loop approval.
You might want to implement this prior to an agent invoking a specific function tool. Useful for medium to high-risk use cases.
Stay tuned.
~
Like What You’ve Read?
Enjoy what you’ve read, have questions about this content, or would like to see another topic? Drop me a note below.
You can schedule a call using my Calendly link to discuss consulting and development services.
~
Further Reading and Resources
You can find more blogs in this series here and other resources to help you on your agentic AI journey.
Tiger Data Launches Agentic Postgres on Tiger Cloud
Tiger Data just made Postgres ready for the AI-native era with Agentic Postgres, now free to try.
With forkable databases for instant, zero-copy branches, hybrid search and APIs that let agents talk directly to your apps, it removes the complexity of building with AI.
Instead of stitching together multiple tools, developers get a unified Postgres based stack this is faster, safer, and cheaper.
Whether using Claude, Cursor, or custom agents, Tiger Cloud gives AI the infrastructure to branch, learn, and build safely.
Start building for free!
It's Monday morning, you have a deadline, and you need to implement a user registration feature.
It's simple enough: save the user, send a welcome email, and track the signup in your analytics dashboard.
You write this:
publicclassUserService(IUserRepository userRepository,IEmailService emailService,IAnalyticsService analyticsService){publicasyncTaskRegisterUser(string email,string password){var user =newUser(email, password);await userRepository.SaveAsync(user);// 1. Directly coupled to email service (external API)await emailService.SendWelcomeEmail(user.Email);// 2. Directly coupled to analytics (this could be an external API)await analyticsService.TrackUserRegistration(user.Id);// What if we need to add more features?// This method will keep growing...}}
It looks clean. It's readable. It works on your machine.
But this method is a ticking time bomb.
It assumes the "Happy Path" is the only path.
It assumes the network is reliable, the email provider is up, and the analytics API is fast.
In production, none of these are guaranteed.
Thinking further, I'm sure you can imagine similar code in your own projects.
It might not be this exact scenario, but the pattern is common: a single method that orchestrates multiple side effects in a linear fashion.
Let's break down why this code is dangerous and how we can refactor it into a robust, event-driven architecture.
When a user clicks "Register," they have to wait for:
The Database +
The SMTP Server +
The Analytics API
If your analytics provider is having a bad day and takes 3 seconds to respond, your user waits 3 seconds.
You are punishing your user for the slowness of a background system they don't even care about.
This is the most critical risk. Imagine this scenario:
SaveAsync(user) succeeds. The user is in the DB.
SendWelcomeEmail succeeds. The user gets an email.
TrackUserRegistration throws a 503 Service Unavailable.
What happens now?
If you wrap this in a transaction and rollback, you have deleted the user from the DB... but you already sent them a welcome email.
The user tries to log in, but they don't exist.
Now what?
If you don't rollback, you have a user in your system that is missing from your analytics.
You have data inconsistency.
You might argue that because we are using interfaces (IEmailService), we are decoupled.
That is true for implementation details, but false for orchestration.
The UserService currently has two reasons to change:
Core Domain Logic: "We now require a username in addition to email."
Notification Policy: "Marketing wants to send an SMS in addition to the Email."
The UserService should strictly be responsible for the state change (creating the user).
It should not be responsible for orchestrating the side effects of that change.
The first step to fixing this is to invert the control.
Instead of the UserServicecommanding other services to do things, it should simply announce that something happened.
publicclassUserService(IUserRepository userRepository,IDomainEventDispatcher dispatcher,IUnitOfWork unitOfWork){publicasyncTaskRegisterUser(string email,string password){// 1. Create the User Entityvar user =newUser(email, password);// 2. Capture the side effect as an event objectvar userRegisteredEvent =newUserRegisteredEvent(user.Id, user.Email);// 3. Add the entity to the repositoryawait userRepository.AddAsync(user);// 4. Dispatch the event (Assuming in-process dispatching here for simplicity)// Note: Handlers for Email and Analytics are now completely separate classes.await dispatcher.Dispatch(userRegisteredEvent);await unitOfWork.SaveChangesAsync();}}
The UserService is now stable.
Adding a "Loyalty Points" feature later doesn't require touching this method.
You just add a new handler for the UserRegisteredEvent.
However, we haven't solved the reliability problem yet.
If the process crashes immediately after Dispatch but before SaveChangesAsync completes, we might send an email for a user that failed to save.
Or, if we save first and dispatch later, we might save the user but lose the event if the server crashes.
Instead of publishing the event immediately to a message bus, we save the event to an OutboxMessages table in the same database transaction as the user.
Here is the complete implementation logic:
publicasyncTaskRegisterUser(string email,string password){// 1. Create the Domain Eventvar user =newUser(email, password);var domainEvent =newUserRegisteredEvent(user.Id, user.Email);// 2. Open a Transactionusingvar transaction = dbContext.Database.BeginTransaction();try{// 3. Save the User to the Users Table dbContext.Users.Add(user);// 4. Serialize the Event and Save to Outbox Tablevar outboxMessage =newOutboxMessage{ Id = Guid.NewGuid(), Type =nameof(UserRegisteredEvent), Content = JsonSerializer.Serialize(domainEvent), OccurredOn = DateTime.UtcNow, ProcessedOn =null// Null means it hasn't been handled yet}; dbContext.OutboxMessages.Add(outboxMessage);// 5. Commit BOTH changes atomicallyawait dbContext.SaveChangesAsync();await transaction.CommitAsync();}catch{await transaction.RollbackAsync();throw;}}
Now, a background worker (running in a separate process) polls the OutboxMessages table.
It picks up the message and publishes it to your message bus (RabbitMQ, Azure Service Bus, etc.).
If the email service is down, the background worker just retries later.
We have achieved At-Least-Once delivery.
The Outbox pattern is perfect for side effects (fire-and-forget actions like emails).
But what if the subsequent action is mandatory?
Scenario: When a user registers, we must create a crypto-wallet for them in the WalletService.
If the wallet creation fails (e.g., due to regulations), we cannot allow the user to exist in our system.
We can't just "retry later" if the WalletService says "Fraud Detected."
We need to undo the user creation.
This is a distributed transaction, and we handle it with the Saga Pattern.
A Saga coordinates a series of steps.
If one fails, it executes Compensating Transactions to undo the previous work.
WalletService: Listens to UserCreated → Tries to create wallet
Failure: Wallet creation fails
Action: Publishes WalletCreationFailed
UserService: Listens to WalletCreationFailed → Deletes/Deactivates the User
This ensures Eventual Consistency.
The system might be inconsistent for a few seconds (the user exists without a wallet),
but it will eventually settle into a valid state (the user is removed).
You don't need Sagas for everything.
Over-engineering is just as bad as tight coupling.
Use this simple rule of thumb:
Is it a simple notification? (Email, Analytics, Cache Invalidation)
Use Domain Events + Outbox. It's okay if it happens 5 seconds later.
Is it a critical business dependency? (Payments, Inventory, Account Status)
Use a Saga. If step B fails, step A must be reverted.
Coupling isn't just about code structure.
It's about understanding and managing failure boundaries.
If your Analytics Service goes down, it shouldn't prevent a user from registering.
Build your systems to survive the unhappy path.
This blog post was created with the help of AI tools. Yes, I used a bit of magic from language models to organize my thoughts and automate the boring parts, but the geeky fun and the in C# are 100% mine.
elbruno.Extensions.AI.Claude just landed on NuGet with dual authentication support, polished samples, and drop-in compatibility with Microsoft.Extensions.AI. Here’s a fast tour so you can start shipping Azure+Claude powered experiences immediately.
Highlights
Works with Claude Sonnet 4.5, Haiku 4.5, and Opus 4.1 hosted in Azure AI Foundry
Implements IChatClient, so it plugs directly into any Microsoft.Extensions.AI pipeline
Supports both DefaultAzureCredential (managed identity, dev tools, service principals) and API key auth paths
Samples for classic responses + streaming straight from the repo
Keep secrets in Azure and let DefaultAzureCredential figure out the right token.
using Azure.Identity;
using elbruno.Extensions.AI.Claude;
using Microsoft.Extensions.AI;
var endpoint = new Uri(Environment.GetEnvironmentVariable("AZURE_CLAUDE_ENDPOINT")!);
var deployment = Environment.GetEnvironmentVariable("AZURE_CLAUDE_MODEL") ?? "claude-haiku-4-5";
var client = new AzureClaudeClient(endpoint, deployment, new DefaultAzureCredential());
var reply = await client.CompleteAsync(new List<ChatMessage>
{
new(ChatRole.System, "Respond in bullet points."),
new(ChatRole.User, "Give me two shipping ideas for a Claude-powered agent."),
});
Console.WriteLine(reply.Message.Text);
Minimal config:
set AZURE_CLAUDE_ENDPOINT=https://<resource>.services.ai.azure.com/anthropic/v1/messages
set AZURE_CLAUDE_MODEL=claude-haiku-4-5
Sample 2 API Key Mode
Need to run outside Azure or keep things lab-simple? Use the API key constructor.
using elbruno.Extensions.AI.Claude;
using Microsoft.Extensions.AI;
var configuration = new ConfigurationBuilder()
.AddUserSecrets<Program>()
.AddEnvironmentVariables()
.Build();
var endpoint = new Uri(configuration["AZURE_CLAUDE_ENDPOINT"]!);
var deployment = configuration["AZURE_CLAUDE_MODEL"] ?? "claude-sonnet-4-5";
var apiKey = configuration["AZURE_CLAUDE_APIKEY"]!; // store in Key Vault or user secrets
var client = new AzureClaudeClient(endpoint, deployment, apiKey);
await foreach (var chunk in client.CompleteStreamingAsync(new List<ChatMessage>
{
new(ChatRole.System, "Keep outputs under 30 words."),
new(ChatRole.User, "Pitch a 1-line elevator speech for this package."),
}))
{
if (!string.IsNullOrEmpty(chunk.Text))
{
Console.Write(chunk.Text);
}
}
Load secrets in dev (user secrets shown; swap for Key Vault in prod):
dotnet user-secrets init
dotnet user-secrets set AZURE_CLAUDE_ENDPOINT "https://<resource>.services.ai.azure.com/anthropic/v1/messages"
dotnet user-secrets set AZURE_CLAUDE_MODEL "claude-sonnet-4-5"
dotnet user-secrets set AZURE_CLAUDE_APIKEY "<api-key>"
Try the Repo Samples
git clone https://github.com/elbruno/elbruno-extensions-ai-claude.git
cd elbruno-extensions-ai-claude
dotnet run --project samples/elbruno.Extensions.AI.Claude.Samples # DefaultAzureCredential flow
dotnet run --project samples/elbruno.Extensions.AI.Claude.ApiKeySample # API key flow
When your Akka.NET application starts dropping 70-80% of incoming connections in production, who do you call? That’s the situation one of our Production Support customers faced this year - and it’s exactly the kind of problem our Akka.NET Support Plans are designed to solve.
Opportunities to purchase developer expertise with a credit card are rare. That’s exactly what we offer - and I want to show you what that looks like in practice.